JSPM

  • Created
  • Published
  • Downloads 28
  • Score
    100M100P100Q61016F
  • License MIT

Universal ADT utilities for TypeScript.

Package Exports

  • unenum

Readme

Install

npm install unenum

Requirements

  • typescript@>=5.0.0
  • tsconfig.json > "compilerOptions" > { "strict": true }

Quickstart

Enum can create discriminated union types.

import { Enum } from "unenum";

type Post = Enum<{
  Ping: true;
  Text: { title?: string; body: string };
  Photo: { url: string };
}>;

... which is identical to if you declared it manually.

type Post =
  | { _type: "Ping" }
  | { _type: "Text"; title?: string; body: string }
  | { _type: "Photo"; url: string };

Enum.define can create discriminated union types and ease-of-use constructors.

const Post = Enum.define(
  {} as {
    Ping: true;
    Text: { title?: string; body: string };
    Photo: { url: string };
  },
);

type Post = Enum.define<typeof Post>;

Constructors can create Enum variant values:

  • All constructed Enum variant values are plain objects.
  • They match their variant types exactly.
  • They do not have any methods or hidden properties.
const posts: Post[] = [
  Post.Ping(),
  Post.Text({ body: "Hello, World!" }),
  Post.Photo({ url: "https://example.com/image.jpg" }),
];

The Enum provides ease-of-use utilities like .match for working with discriminated unions.

(function (post: Post): string {
  if (Enum.match(post, "Ping")) {
    return "Ping!";
  }

  return Enum.match(post, {
    Text: ({ title }) => `Text("${title ?? "Untitled"}")`,
    _: () => `Unhandled`,
  });
});

Enum variant values are simple objects, you can narrow and access properties as you would any other object.

function getTitleFromPost(post: Post): string | undefined {
  return post._type === "Text" ? post.title : undefined;
}
Enum supports creating discriminated unions with custom discriminants. (Click for details…)
type File = Enum<
  {
    "text/plain": { data: string };
    "image/jpeg": { data: Buffer };
    "application/json": { data: unknown };
  },
  "mime"
>;

This creates a discriminated union identical to if you did so manually.

type File =
  | { mime: "text/plain"; data: string }
  | { mime: "image/jpeg"; data: Buffer }
  | { mime: "application/json"; data: unknown };

Enum.* methods for custom discriminants can be accessed via the .on() method.

const File = Enum.on("mime").define(
  {} as {
    "text/plain": { data: string };
    "image/jpeg": { data: Buffer };
    "application/json": { data: unknown };
  },
);

type File = Enum.define<typeof File>;

const files = [
  File["text/plain"]({ data: "..." }),
  File["image/jpeg"]({ data: Buffer.from("...") }),
  File["application/json"]({ data: {} }),
];

(function (file: File): string {
  if (Enum.on("mime").match(file, "text/plain")) {
    return "Text!";
  }

  return Enum.on("mime").match(file, {
    "image/jpeg": ({ data }) => `Image(${data.length})`,
    _: () => `Unhandled`,
  });
});

Result creates a discriminated union with an Ok and Error variant.

import { Result } from "unenum";

export async function getUserCountFromDatabase(): Promise<
  Result<number, "DatabaseError">
> {
  const queriedCount = await Promise.resolve(1);
  return Result.Ok(queriedCount);
}

... which is identical to if you declared it manually.

export async function getUserCountFromDatabase(): Promise<
  { _type: "Ok"; value: number } | { _type: "Error"; error: "DatabaseError" }
> {
  const queriedCount = await Promise.resolve(1);
  return { _type: "Ok", value: queriedCount };
}

Result.from calls a given callback that could throw and returns a Result variant value:

  • Result.Ok with the callback's return value,
  • Result.Error with the callback's thrown error as a value.
export async function getUserCountFromDatabase(): Promise<
  Result<number, "DatabaseError">
> {
  // Database query "throws" if database is unreachable or query fails.
  const $queriedCount = await Result.from(() => queryDatabaseUserCount());

  // Handle error, forward cause.
  if (Enum.match($queriedCount, "Error")) {
    return Result.Error("DatabaseError", $queriedCount);
  }

  return Result.Ok($queriedCount.value);
}
Real-world example. (Click for details…)
export function getTokens(): Tokens | undefined {
  // Retrieve a JSON string to be parsed.
  const tokensSerialised = window.localStorage.getItem("tokens") ?? undefined;
  if (!tokensSerialised) {
    return undefined;
  }

  // JSON.parse "throws" if given an invalid JSON string.
  const $tokensUnknown = Result.from(() => JSON.parse(tokensSerialised));
  if (Enum.match($tokensUnknown, "Error")) {
    return undefined;
  }

  // Tokens.parse "throws" if given a value that doesn't match the schema.
  const $tokens = Result.from(() => Tokens.parse($tokensUnknown.value));
  if (Enum.match($tokens, "Error")) {
    return undefined;
  }

  return $tokens.value;
}

import { z } from "zod";
const Tokens = z.object({ accessToken: z.string(), refreshToken: z.string() });
type Tokens = z.infer<typeof Tokens>;

API

Enum

(type) Enum<TVariants, TDiscriminant?>
  • Creates a discriminated union type from a key-value map of variants.
  • Use true for unit variants that don't have any data properties (not {}).
(Example) Using the default discriminant.
type Foo = Enum<{
  Unit: true;
  Data: { value: string };
}>;
(Example) Using a custom discriminant.
type Foo = Enum<
  {
    Unit: true;
    Data: { value: string };
  },
  "custom"
>;

Enum.define

(func) Enum.define(variants, options?: { [variant]: callback }) => builder
const Foo = Enum.define(
  {} as {
    Unit: true;
    Data: { value: string };
  },
);

type Foo = Enum.define<typeof Foo>;

Enum.match

(func) Enum.match(value, variant | variants[]) => boolean
(func) Enum.match(value, matcher = { [variant]: value | callback; _?: value | callback }) => inferred
(Example) Narrow with one variant.
const foo = Foo.Unit() as Foo;
const value = Enum.match(foo, "Unit");
(Example) Narrow with many variants.
function getFileFormat(file: File): boolean {
  const isText = Enum.on("mime").match(file, [
    "text/plain",
    "application/json",
  ]);
  return isText;
}
(Example) Handle all cases.
const foo: Foo = Foo.Unit() as Foo;
const value = Enum.match(foo, {
  Unit: "Unit()",
  Data: ({ value }) => `Data(${value})`,
});
(Example) Unhandled cases with fallback.
const foo: Foo = Foo.Unit() as Foo;
const value = Enum.match(foo, {
  Unit: "Unit()",
  _: "Unknown",
});

Enum.value

(func) Enum.value(variantName, variantProperties?) => inferred
  • Useful if you add an additional Enum variant but don't have (or want to define) a Enum builder for it.
(Example) Create an Enum value instance, (if possible) inferred from return type.
function getOutput(): Enum<{
  None: true;
  Some: { value: unknown };
  All: true;
}> {
  if (Math.random()) return Enum.value("All");
  if (Math.random()) return Enum.value("Some", { value: "..." });
  return Enum.value("None");
}

Enum.unwrap

(func) Enum.unwrap(result, path) => inferred | undefined
  • Extract a value's variant's property using a "{VariantName}.{PropertyName}" path, otherwise returns undefined.
(Example) Safely wrap throwable function call, then unwrap the Ok variant's value or use a fallback.
const result = Result.from(() => JSON.stringify("..."));
const valueOrFallback = Enum.unwrap(result, "Ok.value") ?? null;

Enum.on

(func) Enum.on(discriminant) => { define, match, value, unwrap }
  • Redefines and returns all Enum.* runtime methods with a custom discriminant.
(Example) Define and use an Enum with a custom discriminant.
const Foo = Enum.on("kind").define({} as { A: true; B: true });
type Foo = Enum.define<typeof Foo>;

const value = Foo.A() as Foo;
Enum.on("kind").match(value, "A");
Enum.on("kind").match(value, { A: "A Variant", _: "Other Variant" });

Enum.Root

(type) Enum.Root<TEnum, TDiscriminant?>
(Example) Infer a key/value mapping of an Enum's variants.
export type Root = Enum.Root<Enum<{ Unit: true; Data: { value: string } }>>;
// -> { Unit: true; Data: { value: string } }

Enum.Keys

(type) Enum.Keys<TEnum, TDiscriminant?>
(Example) Infers all keys of an Enum's variants.
export type Keys = Enum.Keys<Enum<{ Unit: true; Data: { value: string } }>>;
// -> "Unit" | "Data"

Enum.Pick

(type) Enum.Pick<TEnum, TKeys, TDiscriminant?>
(Example) Pick subset of an Enum's variants by key.
export type Pick = Enum.Pick<
  Enum<{ Unit: true; Data: { value: string } }>,
  "Unit"
>;
// -> { _type: "Unit" }

Enum.Omit

(type) Enum.Omit<TEnum, TKeys, TDiscriminant?>
(Example) Omit subset of an Enum's variants by key.
export type Omit = Enum.Omit<
  Enum<{ Unit: true; Data: { value: string } }>,
  "Unit"
>;
// -> *Data

// -> *Green

Enum.Extend

(type) Enum.Extend<TEnum, TVariants, TDiscriminant?>
(Example) Add new variants and merge new properties for existing variants for an Enum.
export type Extend = Enum.Extend<
  Enum<{ Unit: true; Data: { value: string } }>,
  { Extra: true }
>;
// -> *Unit | *Data | *Extra

Enum.Merge

(type) Enum.Merge<TEnums, TDiscriminant?>
(Example) Merge all variants and properties of all given Enums.
export type Merge = Enum.Merge<Enum<{ Left: true }> | Enum<{ Right: true }>>;
// -> *Left | *Right

Result

(type) Result<TOk?, TError?>
  • A helper alias for Result.Ok | Result.Error.

[!NOTE] This "Errors As Values" pattern allows known error cases to handled in a type-safe way, as opposed to throwing errors and relying on the caller to remember to wrap it in try/catch.

(Example) Result without any values.
export function getResult(): Result {
  const isValid = Math.random();

  if (!isValid) {
    return Result.Error();
  }

  return Result.Ok();
}
(Example) Result with Ok and Error values.
export function queryFile(): Result<File, "NotFound"> {
  const fileOrUndefined = getFile();

  if (fileOrUndefined) {
    return Result.Error("NotFound");
  }

  return Result.Ok(file);
}

Result.Ok

(type) Enum.Ok<TOk?>
(func) Enum.Ok(inferred) => Enum.Ok<inferred>
  • Represents a normal/success value, { _type: "Ok"; value: "..." }.

Result.Error

(type) Enum.Error<TError?>
(func) Enum.Error(inferred, cause?) => Enum.Error<inferred>
  • Represents an error/failure value, { _type: "Error"; error: "..."; cause?: ... }.
(func) Result.from(callback)
  • Executes the callback within a try/catch:
    • returns a Enum.Ok with the callback's result,
    • otherwise a Enum.Error with the thrown error (if any).
(Example) Wrap a function that may throw.
const fetchResult = await Result.from(() => fetch("/api/whoami"));

Enum.match(fetchResult, {
  Ok: async ({ value: response }) => {
    const body = (await response.json()) as unknown;
    console.log(body);
  },
  Error: ({ error }) => {
    console.error(error);
  },
});

Pending

(type) Pending
(func) Pending() => Pending
  • Represents an pending state.
  • Ideal for states' values or stateful functions (like React hooks).
(Example) React hook that returns a value, error, or pending state.
import { Pending } from "unenum";

function useFetchedListItems(): Result<string[], "NetworkError"> | Pending {
  const { data, error, loading } = useQuery(gqlListItems);

  if (loading) {
    return Pending();
  }

  if (error || !data) {
    return Result.Error("NetworkError");
  }

  return Result.Ok(data.gqlListItems.items);
}
(Example) React state that could be a loaded value, error, or loading state.
function Component(): Element {
  const [state, setState] = useState<
    Result<{ items: string[] }, "NetworkError"> | Pending
  >(Pending());

  // fetch data and exclusively handle success or error states
  useEffect(() => {
    (async () => {
      const responseResult = await Result.from(() =>
        fetch("/items").then(
          (response) => response.json() as Promise<{ items: string[] }>,
        ),
      );

      if (Enum.match(responseResult, "Error")) {
        setState(Result.Error("NetworkError"));
        return;
      }

      setState(Result.Ok({ items: responseResult.value.items }));
      return;
    })();
  }, []);

  // exhaustively handle all possible states
  return Enum.match(state, {
    Loading: () => `<Spinner />`,
    Ok: ({ value: { items } }) => `<ul>${items.map(() => `<li />`)}</ul>`,
    Error: ({ error }) => `<span>A ${error} error has occurred.</span>`,
  });
}