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
Result
to improve error-handling ergonomics. - includes helpers to inspect/pick/omit/merge/extend Enums and variants.
- includes optional runtime helpers,
is
,match
andResult.from
.
Read more:
npm install unenum
yarn add unenum
typescript@>=5.0.0
tsconfig.json > "compilerOptions" > { "strict": true }
- This
README.ts
is a valid TypeScript file!
- Clone this repo:
git clone [email protected]:peterboyer/unenum.git
. - Install development dependencies:
npm install
oryarn install
. - Jump in and experiment!
import { type Enum } from "unenum";
- The
_type
property 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 }
builder
creates 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: "..." }));
}
- 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: "..." }));
}
is
also 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.";
});
- 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.";
});
match
allows 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.",
});
});
(function (user: User): string {
return match(user, {
Authenticated: ({ userId }) => `Logged in as ${userId}.`,
_: "Unhandled case.",
});
});
- 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";
- Returns
true
and 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"]));
}
- The
matcher
object 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
matcher
cases (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" }));
// ...
}
- These utilities as available as part of the
Enum
type 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 }>;
- Infers a key/value mapping of an Enum's variants.
export type Root = Enum.Root<Signal>;
// { Red: true, Yellow: true; Green: true }
- Infers all keys of an Enum's variants.
export type Keys = Enum.Keys<Signal>;
// "Red" | "Yellow" | "Green"
- 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 | *Yellow
- 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">;
// *Green
- Add new variants and merge new properties for existing variants for an Enum.
export type Extend = Enum.Extend<Signal, { Flashing: true }>;
// *Red | *Yellow | *Green | *Flashing
- Merge all variants and properties of all given Enums.
export type Merge = Enum.Merge<Enum<{ Left: true }> | Enum<{ Right: true }>>;
// *Left | *Right
- Instead of using the default discriminant, all types and utilities can specify a custom discriminant as an optional argument.
export type File = Enum<
{
"text/plain": { data: string };
"image/jpeg": { data: Buffer; compression?: number };
"application/json": { data: unknown };
},
"mime" /* <-- */
>;
- 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("{}") }));
}
{
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("{}") }));
}
- 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";
});
(function (file: File): string {
if (file.mime /* <-- */ === "text/plain") {
return `Text`;
}
if (file.mime /* <-- */ === "image/jpeg") {
return "Image";
}
return "Unsupported";
});
- 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",
});
});
- Represents either a success "value" (
Result.Ok
) or a failure "error" (Result.Error
).
import { Result } from "unenum";
(function (): Result {
if (Math.random()) {
return Result.Error();
}
return Result.Ok();
});
never
may be used for eitherValue
orError
parameters 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);
});
(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;
});
(async function (): Promise<User | undefined> {
const $user = await (async () => ({}) as Promise<Result<User>>)();
return match($user, {
Ok: ({ value: user }) => user,
Error: undefined,
});
});
- The
Result
type defines bothvalue
anderror
properties in bothResult.Ok
andResult.Error
variants, however either variant sets the value of the other as an falsy optionalnever
property. - This allows some cases where if your value is always truthy, you can skip
type narrowing by accepting
undefined
as 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;
});
- Instead of wrapping code that could
throw
intry
/catch
blocks,Result.from
can execute a given callback and return aResult
wrapped value without interrupting a function's control flow or scoping of variables. - If the function throws then the
Error
Result variant is returned, otherwise theOk
Result variant is returned. - The
error
property will always be typed asunknown
because (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);
});
- Represents an asynchronous value that is either loading (
Pending
) or resolved (Ready
). If defined with anEnum
type,Async
will omit itsReady
variant in favour of the "non-pending"Enum
's variants. - Useful for representing states e.g.
use*
hooks.
import { Async } from "unenum";
(function (): Async {
if (Math.random()) {
return Async.Pending();
}
return Async.Ready();
});
const useDeferredName = (): string | undefined => undefined;
(function useName(): Async<string> {
const name = useDeferredName();
if (!name) {
return Async.Pending();
}
return Async.Ready(name);
});
- Which extends the given Enum value type with Async's
Pending
variant. - You can use both
Async
andResult
helpers 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);
});
(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} />`;
});
(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} />`,
});
});
(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} />`;
});