Skip to main content
This package has been archived, and as such it is read-only.
It is unknown whether this package works with Cloudflare Workers, Node.js, Deno, Bun, Browsers
It is unknown whether this package works with Cloudflare Workers
It is unknown whether this package works with Node.js
It is unknown whether this package works with Deno
It is unknown whether this package works with Bun
It is unknown whether this package works with Browsers
JSR Score
52%
Published
4 weeks ago (1.0.0)

Discriminated Union Enums

A modest attempt to recreate the error handling and enums from Rust.

Install

Deno:

deno add @x3ro/discriminated-union-enums

Usage

Example

import { assertEquals } from "@std/assert";
import { match, type Result, Ok, Err, enum_type } from "discriminated-union-enums";

function divide(a: number, b: number): Result<number, string> {
    if (b === 0) {
        // Return an error instead of throwing
        return Err("Cannot divide by zero");
    }

    // Return the Ok variant to signify success
    return Ok(a / b);
}

const result = divide(10, 2);

const matched = match(result, {
    Ok: ({value}) => `The result is ${value}`,
    Err: ({err}) => `Error: ${err}`,
})

assertEquals(matched, "The result is 5");
assertEquals(result[enum_type], "Ok");
import { type Result, enum_type } from "discriminated-union-enums";

function printResult(result: Result<number, string>) {
    // The type system can infer the possible values so the editor can suggest
    // `"Err"` and `"Ok"`.
    if (result[enum_type] === "Err") {
        console.error(`ERROR: ${result.err}`);
        return;
    }

    // The type system can infer that at this point the result must be the `Ok`
    // type so we can access `result.value`.
    console.log(`The result is ${result.value}`);
}

The EnumType

A discriminated union type (or EnumType) is a union of objects that each have the enum_type symbol property set to a unique string value. Each variant can also have other properties that can hold values specific to the variant.

In its most basic form, an EnumType can look like this:

import { enum_type } from "discriminated-union-enums";

export type IpAddrKind = {
    readonly [enum_type]: "V4";
    readonly addr: string;
} | {
    readonly [enum_type]: "V6";
    readonly addr: string;
};

// --- Usage ---
function getIp(): IpAddrKind {
    return {
        [enum_type]: "V4",
        addr: "127.0.0.1",
    };
}

We can also build a much more sophisticated API for our EnumType that allows us to use a namespace for our variants and use a factory to avoid typing { [enum_type]: "..." } every time we want to create a new instance of a variant. It even supports calling functions on each variant:

import { createEnumFactory, enum_type } from "discriminated-union-enums";

// --- Step 1 ---
interface IpAddrKindInterface {
    isPrivate(): boolean;
}

// --- Step 2 ---
class IpAddrKind__V4 implements IpAddrKindInterface {
    readonly [enum_type] = "V4";

    constructor(
        readonly addr: string,
    ) {}

    isPrivate(): boolean {
        return /* ... */
    }
}

class IpAddrKind__V6 implements IpAddrKindInterface {
    readonly [enum_type] = "V6";

    constructor(
        readonly addr: string,
    ) {}

    isPrivate(): boolean {
        return /* ... */
    }
}

// --- Step 3 ---
export const IpAddrKind = createEnumFactory({
    V4: IpAddrKind__V4,
    V6: IpAddrKind__V6,
});

// --- Step 4 ---
export type IpAddrKind = ReturnType<typeof IpAddrKind[keyof typeof IpAddrKind]>;

// --- Step 5 ---
// deno-lint-ignore no-namespace
export namespace IpAddrKind {
    export type V4 = ReturnType<typeof IpAddrKind.V4>;
    export type V6 = ReturnType<typeof IpAddrKind.V6>;
}
  • Step 1: We first define an interface (IpAddrKindInterface) that will be implemented by each variant.
  • Step 2: Then we create a class for each variant. It is important to add the enum_type symbol property with a unique value to each variant.
  • Step 3: Now we use the createEnumFactory helper function to create an object with factory functions for each variant, so that we can create a new instance of a variant by calling IpAddrKind.V4(...) (without new).
  • Step 4: Here we create our actual union type IpAddrKind. We use the TypeScript type system to automatically get all enum variants from the IpAddrKind object from Step 3. This essentially does the following:
    export type IpAddrKind = IpAddrKind__V4 | IpAddrKind__V6;
    
  • Finally, we add a namespace IpAddrKind so that we can use namespaced types for our enum variants like so:
    const ip: IpAddrKind.V4 = // ...
    

This allows us to use IpAddrKind

  • as type (function(ip: IpAddrKind) {}),
  • as namespace for our variant types (function wrapInIPV6(ip: IpAddrKind.V4): IpAddrKind.V6 {})
  • and as factory for our variants (return IpAddrKind.V4(...)).

match()

Matches a discriminated union (or enum) and executes one of the match arms depending on the variant. A match arm is the name of the variant as defined in the enum_type symbol property followed by a function that accepts the variant as an argument.

The match must always be exhaustive, meaning that all variants must be matched. Missing a case will (for now) only result in a TypeScript error but would not cause a runtime error. This behavior might change in the future.

match() can either be used as an expression that returns a value, or as a statement (in which case it would return undefined).

To use it as an expression, each match arm must also return a value:

import { assertEquals } from "@std/assert";
import { match, Some } from "discriminated-union-enums";

const value = Some(1);

const text = match(value, {
    Some: ({value}) => `The value is ${value}`,
    None: () => "No value",
})

assertEquals(text, "The value is 1");

When using it as a statement, the match arms do not need to return a value:

import { match, None, type Option } from "discriminated-union-enums";

function getSomething(): Option<number> {
    return None();
}
const value = getSomething();

match(value, {
    Some: ({value}) => console.log(`The value is ${value}`),
    None: () => console.error("No value"),
})

To match against all other cases, use the _ pattern:

import { match, enum_type } from "discriminated-union-enums";

export type SensorMessage = {
    readonly [enum_type]: "Heartbeat";
} | {
    readonly [enum_type]: "Time";
    readonly time: string;
} | {
    readonly [enum_type]: "Temperature";
    readonly temperature: number;
} | {
    readonly [enum_type]: "Position";
    readonly latitude: number;
    readonly longitude: number;
}

function readNextMessage(): SensorMessage {
    return {
        [enum_type]: "Temperature",
        temperature: 42,
    }
}
const message = readNextMessage();

match(message, {
    Temperature: ({temperature}) => console.log(`Temperature: ${temperature}`),
    Position: ({latitude, longitude}) => console.log(`Position: (${latitude}, ${longitude})`),
    _: () => {}, // Ignore all other cases
})

Testing for a Variant

If you want to test for a specific variant, you can check for the enum_type property:

import { enum_type, Option, Some } from "discriminated-union-enums";

let option = Some(42);

if (option[enum_type] === "Some") {
    console.log(`This is a Some variant: ${option.value}`);
}

You can even narrow down the type if you

import { enum_type, type Option, Some } from "discriminated-union-enums";

function getThing(): Option<number> {
    return Some(1);
}

const thing = getThing();

if (thing[enum_type] === "None") {
    console.log('NONE')
} else {
    // Since we only have two variants, the type system knows that we have
    // a `Some` variant here so we can access the `value` property.
    console.log(thing.value)
}

Predefined Enums

Result

The result type has two variants: Ok and Err. It is used to represent the result of an operation that can possibly fail. The Ok variant holds the successful result, while the Err variant holds any kind of error value. This can for example be a simple string or an object containing information about the error. It is also possible to return a full blown JS Error object with a stack trace but this is not recommended.

For example, let's say we have a function that connects to a database and executes a query. There are multiple possible errors that can occur. Some of which are a connection error or an error with the query itself.

We can use the Result type to represent this:

import { match, type Result, Ok, Err } from "discriminated-union-enums";
import { DbError } from "./examples/DbError.ts";

function query<T extends { [key: string]: any }>(
    query: string
): Result<T, DbError> {
    try {
        // connect to DB
    } catch (err) {
        return Err(DbError.ConnectionError(err));
    }

    let result: T;
    try {
        // send the query
        result = { id: "xSXa5AwZEg", name: "Anon" } as unknown as T;
    } catch (err) {
        return Err(DbError.QueryError(err));
    }

    return Ok(result);
}

const result = query<{ id: string; name: string }>("SELECT * FROM users");

match(result, {
    Ok: ({ value }) => {}, // do something with the result
    Err: ({ err }) => {}, // do something with the error
});

Here the DbError enum is a custom enum that we created for this example. In a real world application, it would consists of all possible errors that can occur when connecting to a database and executing a query. In our example, we limit ourselves to two errors: ConnectionError and QueryError.

Let's assume that we use an external library to handle the database connection and executing the query. This external library might throw a number of errors. We can catch these errors and transform them into our own DbError enum.

We can then match the result of our function to handle the success and error cases explicitly.

Why use Result over throw?

In the end, throwing can achieve the same as the Result type. However, the advantage of using Result over throwing are:

  • Explicit error handling
    Result types force developers to acknowledge and handle errors directly at the call site. Unlike exceptions, which can be silently propagated or accidentally ignored, a Result requires pattern matching (e.g., match in Rust) or combinators (e.g., ?, map, and_then) to access the success value. This reduces the risk of unhandled errors crashing the program unexpectedly.
  • It can be used for flow control
    Exceptions can of course be misused for flow control as well, but they are invisible in the sense that you do not necessarily know which exceptions an operation can throw. Result is a better choice because it is more explicit.
  • Type-Safe error propagation
    Result types make errors part of the function's signature, ensuring the compiler enforces error handling. You know which errors to expect when calling a function, especially when using a Discriminated Union Enum for the error type.

Option

The Option type has two variants: Some and None. It is used to represent the presence or absence of a value. The Some variant holds the value, while the None variant represents the absence of a value. This is useful when you want to explicitly represent optional values in your codebase without relying on null or undefined.

Option can be seen as a special case of the Result type where the error case is unambiguous and the user of an operation can intuitively know why the operation failed.

import { match, type Option, Some, None } from "discriminated-union-enums";

class List<T> {
    constructor(
        private readonly items: T[]
    ) {}

    function first(): Option<T> {
        if (this.items.length === 0) {
            return None;
        }

        return Some(this.items[0]);
    }

    /* ... */
}

const list = new List([1, 2, 3]);

const firstItem = list.first();

if (firstItem[enum_type] === "Some") {
    // The typ system knows that we have a `Some` variant here, so we can
    // access the `value` property.
    console.log(firstItem.value);
} else {
    console.log("List is empty");
}

In this example, the List class has a first method that returns the first item in the list. Usually we would return null or undefined if the list is empty, but using Option we can make it explicit that there might not be a first item and we force the user of our function to handle this case.

Why use Option over null or undefined?

While null and undefined can be used to represent the absence of a value, Option is a more explicit way to express that a value may or may not be present.

Add Package

deno add jsr:@bjoric-test/discriminated-union-enums

Import symbol

import * as discriminated_union_enums from "@bjoric-test/discriminated-union-enums";

---- OR ----

Import directly with a jsr specifier

import * as discriminated_union_enums from "jsr:@bjoric-test/discriminated-union-enums";

Add Package

npx jsr add @bjoric-test/discriminated-union-enums

Import symbol

import * as discriminated_union_enums from "@bjoric-test/discriminated-union-enums";

Add Package

yarn dlx jsr add @bjoric-test/discriminated-union-enums

Import symbol

import * as discriminated_union_enums from "@bjoric-test/discriminated-union-enums";

Add Package

pnpm dlx jsr add @bjoric-test/discriminated-union-enums

Import symbol

import * as discriminated_union_enums from "@bjoric-test/discriminated-union-enums";

Add Package

bunx jsr add @bjoric-test/discriminated-union-enums

Import symbol

import * as discriminated_union_enums from "@bjoric-test/discriminated-union-enums";