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 callingIpAddrKind.V4(...)
(withoutnew
). - Step 4: Here we create our actual union type
IpAddrKind
. We use the TypeScript type system to automatically get all enum variants from theIpAddrKind
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, throw
ing 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";