practical-fp / union-types

A Typescript library for creating discriminating union types.
MIT License
70 stars 2 forks source link

Union Types

NPM version badge Bundle size badge Dependency count badge Tree shaking support badge License badge

A Typescript library for creating discriminating union types. Requires Typescript 3.5 or higher.

Typescript Handbook on discriminating union types

Example

import { impl, matchExhaustive, Variant } from "@practical-fp/union-types"

type Shape =
    | Variant<"Circle", { radius: number }>
    | Variant<"Square", { sideLength: number }>

const { Circle, Square } = impl<Shape>()

function getArea(shape: Shape) {
    return matchExhaustive(shape, {
        Circle: ({ radius }) => Math.PI * radius ** 2,
        Square: ({ sideLength }) => sideLength ** 2,
    })
}

const circle = Circle({ radius: 5 })
const area = getArea(circle)

Installation

$ npm install @practical-fp/union-types

Usage

Defining a discriminating union type

import { Variant } from "@practical-fp/union-types"

type Shape =
    | Variant<"Circle", { radius: number }>
    | Variant<"Square", { sideLength: number }>

This is equivalent to the following type:

type Shape =
    | { tag: "Circle", value: { radius: number } }
    | { tag: "Square", value: { sideLength: number } }

Creating an implementation

import { impl } from "@practical-fp/union-types"

const { Circle, Square } = impl<Shape>()

impl<>() can only be used if your environment has full support for Proxies. Alternatively, use the constructor<>() function.

import { constructor } from "@practical-fp/union-types"

const Circle = constructor<Shape, "Circle">("Circle")
const Square = constructor<Shape, "Square">("Square")

Circle and Square can then be used to wrap values as a Shape.

const circle: Shape = Circle({ radius: 5 })
const square: Shape = Square({ sideLength: 3 })

Circle.is and Square.is can be used to check if a shape is a circle or a square. They also act as a type guard.

const shapes: Shape[] = [circle, square]
const sideLengths = shapes.filter(Square.is).map(square => square.value.sideLength)

You can also create custom implementations using the tag() and predicate() helper functions.

import { predicate, tag } from "@practical-fp/union-types"

const Circle = (radius: number) => tag("Circle", { radius })
const isCircle = predicate("Circle")

const Square = (sideLength: number) => tag("Square", { sideLength })
const isSquare = predicate("Square")

Matching against a union

import { matchExhaustive } from "@practical-fp/union-types"

function getArea(shape: Shape) {
    return matchExhaustive(shape, {
        Circle: ({ radius }) => Math.PI * radius ** 2,
        Square: ({ sideLength }) => sideLength ** 2,
    })
}

matchExhaustive() is exhaustive, i.e., you need to match against every variant of the union. Cases can be omitted when using a wildcard case with matchWildcard().

import { matchWildcard, WILDCARD } from "@practical-fp/union-types"

function getDiameter(shape: Shape) {
    return matchWildcard(shape, {
        Circle: ({ radius }) => radius * 2,
        [WILDCARD]: () => undefined,
    })
}

switch-statements can also be used to match against a union.

import { assertNever } from "@practical-fp/union-types"

function getArea(shape: Shape) {
    switch (shape.tag) {
        case "Circle":
            return Math.PI * shape.value.radius ** 2
        case "Square":
            return shape.value.sideLength ** 2
        default:
            // exhaustiveness check
            // compile-time error if a case is missing
            assertNever(shape)  
    }
}

Generics

impl<>() and constructor<>() also support generic union types.

In case the variant type uses unconstrained generics, unknown needs to be passed as its type arguments.

import { impl, Variant } from "@practical-fp/union-types"

type Result<T, E> =
    | Variant<"Ok", T>
    | Variant<"Err", E>

const { Ok, Err } = impl<Result<unknown, unknown>>()

In case the variant type uses constrained generics, the constraint type needs to be passed as its type arguments.

import { impl, Variant } from "@practical-fp/union-types"

type Result<T extends object, E> =
    | Variant<"Ok", T>
    | Variant<"Err", E>

const { Ok, Err } = impl<Result<object, unknown>>()

strictImpl<>() and strictConstructor<>()

impl<>() and constructor<>() generate generic constructor functions. This may not always be desirable.

import { impl } from "@practical-fp/union-types"

const { Circle } = impl<Shape>()
const circle = Circle({
    radius: 5,
    color: "red",
})

Since Circle is generic, it's perfectly fine to pass extra properties other than radius.

To prevent that, we can use strictImpl<>() or strictConstructor<>() to create a strict implementation which is not generic.

import { strictImpl } from "@practical-fp/union-types"

const { Circle } = strictImpl<Shape>()
const circle = Circle({
    radius: 5,
    color: "red",  // compile error
})