2.0.0 • Published 12 months ago
unenum v2.0.0
unenum
Universal ADT utilities for TypeScript.
Installation • Enum • builder •
is • match • Result •
Result.from • Async
- produces simple and portable discriminated union types.
- all types can be compiled away, with zero-cost to bundle size.
- supports custom discriminants for type utilities and runtime helpers.
- includes
Resultto improve error-handling ergonomics. - includes helpers to inspect/pick/omit/merge/extend Enums and variants.
- includes optional runtime helpers,
is,matchandResult.from.
Read more:
Installation
npm install unenumyarn add unenumRequirements
typescript@>=5.0.0tsconfig.json > "compilerOptions" > { "strict": true }
Playground
- This
README.tsis a valid TypeScript file!
- Clone this repo:
git clone git@github.com:peterboyer/unenum.git. - Install development dependencies:
npm installoryarn install. - Jump in and experiment!
Enum
import { type Enum } from "unenum";Defining an Enum
- The
_typeproperty is used as discriminant to distinguish between variants. - The underscore-prefixed name somewhat denotes this as a special property not intended to collide with general-use user-space named properties.
export type User = Enum<{
Anonymous: true;
Authenticated: { userId: string };
}>;
// | { _type: "Anonymous" }
// | { _type: "Authenticated", userId: string }Instantiating an Enum
(a) builder function
buildercreates an Enum value "constructor" typed with a given Enum type.- You may define and export the builder with the same name as your Enum's type.
export const User = builder({} as User);
{
const user: User = User.Anonymous();
void user;
void (() => User.Anonymous());
void (() => User.Authenticated({ userId: "..." }));
}(b) object expression
- Alternatively, you may chose to not use a builder.
{
const user: User = { _type: "Anonymous" };
void user;
void ((): User => ({ _type: "Anonymous" }));
void ((): User => ({ _type: "Authenticated", userId: "..." }));
}Using an Enum
(a.1) if statements, type-guard helper
isalso allows for matching using an array of multiple variants' keys.
(function (user: User): string {
if (is(user, "Authenticated")) {
return `Logged in as ${user.userId}.`;
}
return "Not logged in.";
});(a.2) if statements, property access
- Alternatively, you may chose to not use a matcher.
(function (user: User): string {
if (user._type === "Authenticated") {
return `Logged in as ${user.userId}.`;
}
return "Not logged in.";
});(b.1) match helper, handling all cases
matchallows easy type safe mapping of variants and variants' values to another returned value.
(function (user: User): string {
return match(user, {
Authenticated: ({ userId }) => `Logged in as ${userId}.`,
Anonymous: "Not logged in.",
});
});(b.2) match helper, with catch-all
(function (user: User): string {
return match(user, {
Authenticated: ({ userId }) => `Logged in as ${userId}.`,
_: "Unhandled case.",
});
});builder
- Returns a constructor based on the given Enum type to easily create variant object values.
- A custom "mapper" can be used to define functions per Enum variant to streamline construction of Enum variants based on your use-cases.
type Colour = Enum<{
Transparent: true;
Named: { name: string };
RGB: Record<"r" | "g" | "b", number>;
}>;
export const Colour = builder({} as Colour, {
RGB: (r: number, g: number, b: number) => ({ r, g, b }),
});
{
const color: Colour = Colour.RGB(4, 2, 0);
void color;
// variant with no properties
void ((): Colour => Colour.Transparent());
// variant with properties
void ((): Colour => Colour.Named({ name: "Red" }));
// variant with mapper function
void ((): Colour => Colour.RGB(0, 0, 0));
}import { builder } from "unenum";is
- Returns
trueand narrows the given Enum value's possible variants if the value matches any of the specified variants by key.
import { is } from "unenum";
{
type Value = Enum<{ A: true; B: { value: string } }>;
const value = {} as Value;
void (() => is(value, "A"));
void (() => is(value, "B"));
void (() => is(value, ["A"]));
void (() => is(value, ["A", "B"]));
}import { is_ } from "unenum";
{
type Value = Enum<{ A: true; B: { value: string } }, "custom">;
const value = {} as Value;
void (() => is_(value, "custom", "A"));
void (() => is_(value, "custom", "B"));
void (() => is_(value, "custom", ["A"]));
void (() => is_(value, "custom", ["A", "B"]));
}match
- The
matcherobject is keyed with all possible variants of the Enum and an optional_fallback case. - If the
_fallback case is not given, all variants must be specified. - All
matchercases (including_) can be a value or a callback. - If a variant's case is a callback, the matching variants value's properties are available for access.
import { match } from "unenum";
{
const value = {} as Enum<{ A: true; B: { value: string } }>;
void (() => match(value, { _: "Fallback" }));
void (() => match(value, { _: () => "Fallback" }));
void (() => match(value, { A: "A", _: "Fallback" }));
void (() => match(value, { A: () => "A", _: "Fallback" }));
void (() => match(value, { A: "A", B: "B" }));
void (() => match(value, { A: "A", B: () => "B" }));
void (() => match(value, { A: () => "A", B: () => "B" }));
void (() => match(value, { A: () => "A", B: () => "B", _: "Fallback" }));
void (() => match(value, { A: undefined, B: ({ value }) => value }));
void (() => match(value, { B: ({ value }) => value, _: "Fallback" }));
void (() => match(value, { A: true, B: false, _: undefined }));
}import { match_ } from "unenum";
{
const value = {} as Enum<{ A: true; B: { value: string } }, "custom">;
void (() => match_(value, "custom", { _: "Fallback" }));
void (() => match_(value, "custom", { A: "A", B: "B" }));
void (() => match_(value, "custom", { A: "A", _: "Fallback" }));
// ...
}Manipulating an Enum
- These utilities as available as part of the
Enumtype import's namespace. - All of these Enum type utilities support a custom discriminant as the last
type parameter, e.g.
Enum.Root<Signal, "custom">.
// example
type Signal = Enum<{ Red: true; Yellow: true; Green: true }>;Enum.Root
- Infers a key/value mapping of an Enum's variants.
export type Root = Enum.Root<Signal>;
// { Red: true, Yellow: true; Green: true }Enum.Keys
- Infers all keys of an Enum's variants.
export type Keys = Enum.Keys<Signal>;
// "Red" | "Yellow" | "Green"Enum.Pick
- Pick subset of an Enum's variants by key.
export type PickRed = Enum.Pick<Signal, "Red">;
// *Red
export type PickRedYellow = Enum.Pick<Signal, "Red" | "Yellow">;
// *Red | *YellowEnum.Omit
- Omit subset of an Enum's variants by key.
export type OmitRed = Enum.Omit<Signal, "Red">;
// *Yellow | *Green
export type OmitRedYellow = Enum.Omit<Signal, "Red" | "Yellow">;
// *GreenEnum.Extend
- Add new variants and merge new properties for existing variants for an Enum.
export type Extend = Enum.Extend<Signal, { Flashing: true }>;
// *Red | *Yellow | *Green | *FlashingEnum.Merge
- Merge all variants and properties of all given Enums.
export type Merge = Enum.Merge<Enum<{ Left: true }> | Enum<{ Right: true }>>;
// *Left | *RightEnums with custom discriminants
- Instead of using the default discriminant, all types and utilities can specify a custom discriminant as an optional argument.
Defining
export type File = Enum<
{
"text/plain": { data: string };
"image/jpeg": { data: Buffer; compression?: number };
"application/json": { data: unknown };
},
"mime" /* <-- */
>;Instantiating
(a) builder function
- Use
builder_which requires the discriminant to be passed as an argument.
import { builder_ } from "unenum";
export const File = builder_({} as File, "mime" /* <-- */);
{
const file: File = File["text/plain"]({ data: "..." });
void file;
void (() => File["text/plain"]({ data: "..." }));
void (() => File["image/jpeg"]({ data: Buffer.from("...") }));
void (() => File["application/json"]({ data: JSON.parse("{}") }));
}(b) object expression
{
const file: File = { mime: "text/plain", data: "..." };
void file;
void ((): File => ({ mime: "text/plain", data: "..." }));
void ((): File => ({ mime: "image/jpeg", data: Buffer.from("...") }));
void ((): File => ({ mime: "application/json", data: JSON.parse("{}") }));
}Using
(a.1) if statements, type-guard helper
- Use
is_which requires the discriminant to be passed as an argument.
(function (file: File): string {
if (is_(file, "mime" /* <-- */, "text/plain")) {
return `Text`;
}
if (is_(file, "mime" /* <-- */, "image/jpeg")) {
return "Image";
}
return "Unsupported";
});(a.2) if statements, property access
(function (file: File): string {
if (file.mime /* <-- */ === "text/plain") {
return `Text`;
}
if (file.mime /* <-- */ === "image/jpeg") {
return "Image";
}
return "Unsupported";
});(b) match helper
- Use
match_which requires the discriminant to be passed as an argument.
(function (file: File): string {
return match_(file, "mime" /* <-- */, {
"text/plain": () => "Text",
"image/jpeg": () => "Image",
_: () => "Unsupported",
});
});Result
- Represents either a success "value" (
Result.Ok) or a failure "error" (Result.Error).
import { Result } from "unenum";Result without a value or error.
(function (): Result {
if (Math.random()) {
return Result.Error();
}
return Result.Ok();
});Result with a "value" and/or "error"
nevermay be used for eitherValueorErrorparameters if only the base variant is needed without any value.
(function (): Result<User, "NotFound"> {
const user = {} as User | undefined;
if (!user) {
return Result.Error("NotFound");
}
return Result.Ok(user);
});Using a Result value
(a) narrowing
(async function (): Promise<User | undefined> {
const $user = await (async () => ({}) as Promise<Result<User>>)();
// handle error
if (is($user, "Error")) {
return undefined;
}
// continue with value
const user = $user.value;
return user;
});(b) matching
(async function (): Promise<User | undefined> {
const $user = await (async () => ({}) as Promise<Result<User>>)();
return match($user, {
Ok: ({ value: user }) => user,
Error: undefined,
});
});(c) value or undefined by property access
- The
Resulttype defines bothvalueanderrorproperties in bothResult.OkandResult.Errorvariants, however either variant sets the value of the other as an falsy optionalneverproperty. - This allows some cases where if your value is always truthy, you can skip
type narrowing by accepting
undefinedas the properties possible states.
(async function (): Promise<User | undefined> {
const $user = await (async () => ({}) as Promise<Result<User>>)();
const user = $user.value;
// User | undefined
return user;
});Result.from
- Instead of wrapping code that could
throwintry/catchblocks,Result.fromcan execute a given callback and return aResultwrapped value without interrupting a function's control flow or scoping of variables. - If the function throws then the
ErrorResult variant is returned, otherwise theOkResult variant is returned. - The
errorproperty will always be typed asunknownbecause (unfortunately) in JavaScript, anything from anywhere can be thrown as an error.
const getValueOrThrow = (): string => {
if (Math.random()) {
throw new Error("Failure");
}
return "Success";
};
(function () {
const result = Result.from(() => getValueOrThrow());
// Result<string, unknown>
if (is(result, "Error")) {
// handle error
console.error(result.error);
return;
}
// (safely) continue with value
console.info(result.value);
});Async
- Represents an asynchronous value that is either loading (
Pending) or resolved (Ready). If defined with anEnumtype,Asyncwill omit itsReadyvariant in favour of the "non-pending"Enum's variants. - Useful for representing states e.g.
use*hooks.
import { Async } from "unenum";Async without a value
(function (): Async {
if (Math.random()) {
return Async.Pending();
}
return Async.Ready();
});Async with a non-Enum value
const useDeferredName = (): string | undefined => undefined;
(function useName(): Async<string> {
const name = useDeferredName();
if (!name) {
return Async.Pending();
}
return Async.Ready(name);
});Async with a Enum value
- Which extends the given Enum value type with Async's
Pendingvariant. - You can use both
AsyncandResulthelpers together.
const useResource = <T>() => [{} as T | undefined, { loading: false }] as const;
(function useUser(): Async<Result<User, "NotFound">> {
const [user, { loading }] = useResource<User | null>();
if (loading) {
return Async.Pending();
}
if (!user) {
return Result.Error("NotFound");
}
return Result.Ok(user);
});Using a Async value
(a) narrowing
(function Component(): string {
const $user = (() => ({}) as Async<Result<User, "E">>)();
if (is($user, "Pending")) {
return `<Loading />`;
}
// handle error
if (is($user, "Error")) {
const { error } = $user;
return `<Error error=${error} />`;
}
// continue with value
const user = $user.value;
return `<Profile user=${user} />`;
});(b) matching
(function Component() {
const $user = (() => ({}) as Async<Result<User, unknown>>)();
return match($user, {
Pending: () => `<Loading />`,
Error: ({ error }) => `<Error error=${error} />`,
Ok: ({ value: user }) => `<Profile user=${user} />`,
});
});(c) value or undefined property access
(function Component() {
const $user = (() => ({}) as Async<Result<User, "E">>)();
if (is($user, "Pending")) {
return `<Loading />`;
}
const user = $user.value;
// User | undefined
return `<Profile user=${user} />`;
});3.0.0-sha.93a3ee7
12 months ago
3.0.0-sha.e2d40e8
12 months ago
3.0.0-sha.159037f
1 year ago
2.0.0-sha.6207c26
2 years ago
2.0.0-sha.9210348
2 years ago
2.0.0
2 years ago
2.0.0-sha.8b7dd96
2 years ago
2.0.0-sha.d6f4a4d
2 years ago
2.0.0-sha.f8ebf55
2 years ago
2.0.0-sha.9c46c48
2 years ago
1.1.0
2 years ago
1.0.0
2 years ago
2.0.0-sha.198a4ba
2 years ago
1.0.0-sha.88f49c4
3 years ago
1.0.0-sha.80298e1
3 years ago
1.0.0-sha.e460942
3 years ago
1.0.0-sha.f2a1773
3 years ago
1.0.0-sha.fee2e53
3 years ago
1.0.0-sha.9589221
3 years ago
1.0.0-sha.66d9b9b
3 years ago
1.0.0-sha.a0a078d
3 years ago
1.0.0-sha.8971ff6
3 years ago
0.0.0
3 years ago