Skip to content

Instantly share code, notes, and snippets.

@kourge
Last active September 15, 2023 00:07
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kourge/9715e0dd59c28e776fb598d407636106 to your computer and use it in GitHub Desktop.
Save kourge/9715e0dd59c28e776fb598d407636106 to your computer and use it in GitHub Desktop.

Dynamic methods on a TypeScript class

Sometimes, we want to be able to generalize a class that is tailored to some static set of data, but want to do so while preserving type safety. This can be achieved in TypeScript by combining userland enums, private constructors, mapped types, and intersection types.

Individual techniques

Each of the individual techniques above are known TypeScript patterns built on top of existing JavaScript patterns.

Userland enums

Starting in 2.4, TypeScript natively supports string enums:

enum Color {
    Red = 'R',
    Green = 'G',
    Blue = 'B',
}

This is basically a shorthand for userland enums:

namespace Color {
    export const Red = 'R';
    export const Green = 'G';
    export const Blue = 'B';
}
type Color = typeof Color.Red | typeof Color.Green | typeof Color.Blue;

The biggest difference between a userland enum and a native enum is that a native enum is nominally typed. Therefore, two identical enums are not mutually compatible types, which is surprising behavior since the rest of TypeScript does not work this way:

enum S1 {
    On = 'on',
    Off = 'off',
}

enum S2 {
    On = 'on',
    Off = 'off',
}

const x: S1 = S1.On;
const y: S2 = x; // error

Due to this, it is recommended that when making a publically consumable API, prefer userland enums over native enums. To make it easier to generate a userland enum, use the typescript-string-enums package:

import {Enum} from 'typescript-string-enums';

const Color = Enum({
    Red: 'R',
    Green: 'G',
    Blue: 'B',
});
type Color = Enum<typeof Color>;

Private constructors

TypeScript already offers keywords like private and protected to allow some level of encapsulation that one can expect out of class-based inheritance, but they are all compile-time constructs that offer no true protection against a consumer inadvertently using a private API.

The private constructor pattern leverages the export semantics of ECMAScript modules to create this sort of protection by only exporting façades to a class while keeping the true class definition hidden:

class Point {
    x: number;
    y: number;
    constructor(x: number, y: number) {}
}

export function point(x: number, y: number): Point {
    return new Point(x, y);
}

As an added bonus, the module can export multiple façades to offer "convenience constructors".

Mapped types

In TypeScript, a mapped type can be used to "map" a type into another. This is difficult to reason about abstractly, so the best way to demonstrate this is with an example. Suppose we have the following function promisifyObject that returns a new object where all of its values are instead resolved promises:

function promisifyObject(o) {
    const result = {};
    for (const k in Object.keys(o)) {
        result[k] = Promise.resolve(o[k]);
    }
    return result;
}

How would we describe this function in TypeScript? In other words, what do we write in place of R?

declare function promisifyObject<T extends object>(o: T): R;

Starting in TypeScript 2.1, we can describe R as thus:

declare function promisifyObject<T extends object>(
    o: T,
): {[K in T]: Promise<T[K]>};

With mapped types, we are able to describe to the type system the idea that we want a new object type based on an old type, except all its values are wrapped in a promise.

Intersection types

The idea of intersection types is crucial to object composition. Suppose that we have the following JavaScript:

interface Point {
    x: number;
    y: number;
}
interface Index {
    z: number;
}
const point: Point = {x: 1, y: 2};
const index: Index = {z: 5};
const location = {...point, ...index};

What is the type of location? With intersection types, the answer is Point & Index. Intersection types intuitively describe what happens in JavaScript when object spreading or Object.assign is used.

Phase 1: Parameterization

Suppose that we create a class that records multiple boolean conditions, each of which can be named:

export class FactTable {
    private conditions: {[name: string]: boolean};

    constructor(conditions: {[name: string]: boolean}) {
        this.conditions = conditions;
    }

    get(fact: string): boolean | undefined {
        return this.conditions[fact];
    }

    set(fact: string, value: boolean): void {
        this.conditions[fact] = value;
    }

    isAllTrue(): boolean {
        return Object.values(this.conditions).every(b => b);
    }

    isAnyTrue(): boolean {
        return Object.values(this.conditions).some(b => b);
    }
}

We can use it like this:

const hireable = new FactTable({
    isCitizen: false,
    isPermanentResident: false,
    hasWorkVisa: false,
    hasStudentVisa: false,
});
hireable.set('isPermanentResident', true);
const canHire = hireable.isAnyTrue();

Note that the methods get and set are not type-safe at all:

  • We can call get for an unknown fact name. In that case, we'd get undefined back.
  • We can call set for any fact name, even if it is hitherto unknown.

Now suppose that every time we need to use a FactTable, we know the name of every boolean ahead of time, and the number of booleans do not change. This list of booleans can be modeled with a string enum:

import {Enum} from 'typescript-string-enums';

export const LegalStatus = Enum(
    'isCitizen',
    'isPermanentResident',
    'hasWorkVisa',
    'hasStudentVisa',
);
export type LegalStatus = Enum<typeof LegalStatus>;

We can now parameterize the FactTable class on the enum keys:

export class FactTable<Fact extends string> {
    constructor(private conditions: {[K in Fact]: boolean}) {}

    get(fact: Fact): boolean {
        return this.conditions[fact];
    }

    set(fact: Fact, value: boolean): void {
        this.conditions[fact] = value;
    }

    isAllTrue(): boolean {
        return Object.values(this.conditions).every(b => b);
    }

    isAnyTrue(): boolean {
        return Object.values(this.conditions).some(b => b);
    }
}

Not only are we able to safely remove undefined as a possible return type of the get method, both the get and the set methods are keyed against a known set of facts:

const hireable = new FactTable<LegalStatus>({
    isCitizen: false,
    isPermanentResident: false,
    hasWorkVisa: false,
    hasStudentVisa: false,
});
hireable.set('isPermanentResident', true);
const canHire = hireable.isAnyTrue();

It is now impossible to set or get an unknown fact.

Phase 2: Constructor façade

We now have a type-safe interface for a FactTable:

interface FactTable<Fact> {
    get(fact: Fact): boolean;
    set(fact: Fact, value: boolean): void;
    isAllTrue(): boolean;
    isAnyTrue(): boolean;
}

(Recall that when we define a class in TypeScript, we are doing two things at once: defining an interface that describes an instance of the class, as well as defining a function value that can be newed to produce an instance of the class.)

Let's make the class unexported, and make an exported function that delegates to the constructor:

class _FactTable<Fact extends string> {
    constructor(private conditions: {[K in Fact]: boolean}) {}

    get(fact: Fact): boolean {
        return this.conditions[fact];
    }

    set(fact: Fact, value: boolean): void {
        this.conditions[fact] = value;
    }

    isAllTrue(): boolean {
        return Object.values(this.conditions).every(b => b);
    }

    isAnyTrue(): boolean {
        return Object.values(this.conditions).some(b => b);
    }
}

export interface FactTable<Fact extends string> extends _FactTable<Fact> {}

export function factTable<Fact extends string>(
    conditions: {[K in Fact]: boolean},
): FactTable<Fact> {
    return new _FactTable(conditions);
}

We deliberately hide the actual class, but expose everything necessary to construct and type a fact table class.

Phase 3: Generating dynamic methods

Suppose we now want to generate multiple pre-bound setters for every known fact. With LegalStatus, the setters would collectively form the following object types, represented as these hypothetical interfaces:

interface __SetterGroup {
    isCitizen: () => void;
    isPermanentResident: () => void;
    hasWorkVisa: () => void;
    hasStudentVisa: () => void;
}

interface __Settable {
    setTrue: __SetterGroup;
    setFalse: __SetterGroup;
}

We would use them like this:

hireable.setTrue.isPermanentResident();

We want to take every LegalStatus enum value and generate functions that call the set method with a known fact and a chosen value:

function settersForLegalStatus(
    factTable: FactTable<LegalStatus>,
    value: boolean,
): __SetterGroup {
    const methods = {} as __SetterGroup;
    for (const enumName of Enum.keys(LegalStatus)) {
        const enumValue = LegalStatus[enumName];
        methods[enumName] = () => factTable.set(enumValue, value);
    }
    return methods;
}

function withSettersForLegalStatus(
    factTable: FactTable<LegalStatus>,
): FactTable<LegalStatus> & __Settable {
    return Object.assign(factTable, {
        setTrue: settersForLegalStatus(factTable, true),
        setFalse: settersForLegalStatus(factTable, false),
    });
}

Note a few things:

  • We used Enum.keys to list all the enum keys in a type-safe way. This utility function is provided by typescript-string-enums.
  • We have to cast {} as __SetterGroup, because the object hasn't been filled out yet.
  • We make the distinction between an enum value and the name it is assigned to. In the case of LegalStatus, they are one and the same, but they don't necessarily have to be the same.
  • We copy the additional methods onto an existing FactTable object. This ensures the prototype is the same and we don't lose the existing methods.

We will initially use withSettersForLegalStatus like this:

const hireable = withSettersForLegalStatus(
    factTable<LegalStatus>({
        isCitizen: false,
        isPermanentResident: false,
        hasWorkVisa: false,
        hasStudentVisa: false,
    }),
);
hireable.setTrue.isPermanentResident();
const canHire = hireable.isAnyTrue();

Phase 4: Describing dynamic methods

Recall the type of the setter group that we previously wrote, repeated here:

interface __SetterGroup {
    isCitizen: () => void;
    isPermanentResident: () => void;
    hasWorkVisa: () => void;
    hasStudentVisa: () => void;
}

Note the shape of this object type: all its values are the same type, and all its keys are from the LegalStatus enum. This is something that can be described by a mapped type! It looks like this:

type __SetterGroup = {[MethodName in LegalStatus]: () => void};

It's fairly common to generate a mapped type where the values are all the same type and only the keys vary, so TypeScript ships with a built-in type called Record<K, V> that does this for us:

type __SetterGroup = Record<LegalStatus, () => void>;

We can now abstract this type for any group of keys:

export namespace factTable {
    /**
     * A collection of methods that set a particular `Fact` to `true` or
     * `false`.
     */
    export type SetterGroup<Fact extends string> = Record<Fact, () => void>;

    /**
     * Describes the extra methods that a `FactTable` can have.
     */
    export interface Settable<Fact extends string> {
        setTrue: SetterGroup<Fact>;
        setFalse: SetterGroup<Fact>;
    }
}

Phase 5: Generalizing method generation

Our prior versions were specific to the LegalStatus enum. Now that we've worked out how to describe dynamic methods to the type system without tying it specifically to the LegalStatus enum, let's rewrite the method-generating functions to do the same thing:

export namespace factTable {
    /**
     * Represents any string enum, an object whose keys and values are all
     * strings.
     */
    export type AnyStringEnum = {[name: string]: string};

    /**
     * Given an enum type, yields a union of all its known enum values.
     */
    export type ValueOf<_Enum extends AnyStringEnum> = _Enum[keyof _Enum];

    /**
     * Generates a `SetterGroup` given an `_enum`, a `factTable` to which the
     * methods should be bound, and a `value` that the methods should use.
     */
    export function settersFor<_Enum extends AnyStringEnum>(
        _enum: _Enum,
        factTable: FactTable<ValueOf<_Enum>>,
        value: boolean,
    ): SetterGroup<ValueOf<_Enum>> {
        const methods = {} as SetterGroup<ValueOf<_Enum>>;
        for (const enumName of Enum.keys(_enum)) {
            const enumValue = _enum[enumName];
            methods[enumName] = () => factTable.set(enumValue, value);
        }
        return methods;
    }

    /**
     * Adds setter methods to the `factTable` using the given `_enum`, then
     * returns the augmented `factTable`.
     */
    export function withSetters<_Enum extends AnyStringEnum>(
        _enum: _Enum,
        factTable: FactTable<ValueOf<_Enum>>,
    ): FactTable<ValueOf<_Enum>> & Settable<ValueOf<_Enum> {
        return Object.assign(factTable, {
            setTrue: settersFor(_enum, factTable, true),
            setFalse: settersFor(_enum, factTable, false),
        });
    }
}

The function withSetters augments a fact table instance with additional methods. Its return type uses an intersection type to denote that it doesn't just produce a plain old fact table, but rather, a fact table with all those additional methods.

Phase 6: Tying it all together

We can now combine the original fact table class and façade with all the dynamic method generation facility we've developed, the result of which is reproduced below:

class _FactTable<Fact extends string> {
    constructor(private conditions: {[K in Fact]: boolean}) {}

    get(fact: Fact): boolean {
        return this.conditions[fact];
    }

    set(fact: Fact, value: boolean): void {
        this.conditions[fact] = value;
    }

    isAllTrue(): boolean {
        return Object.values(this.conditions).every(b => b);
    }

    isAnyTrue(): boolean {
        return Object.values(this.conditions).some(b => b);
    }
}

/**
 * Represents one or more facts that are either true or false. Each of the
 * facts is statically known, and is represented as a string literal union.
 */
export interface FactTable<Fact extends string> extends _FactTable<Fact> {}

/**
 * Creates a fact table, given an enum of known facts and a complete set of
 * initial conditions that specify the truth of every known fact.
 */
export function factTable<_Enum extends AnyStringEnum>(
    _enum: _Enum,
    conditions: {[K in factTable.ValueOf<_Enum>]: boolean},
): FactTable<factTable.ValueOf<_Enum>> {
    return factTable.withSetters(_enum, new _FactTable(conditions));
}

export namespace factTable {
    /**
     * Represents any string enum, an object whose keys and values are all
     * strings.
     */
    export type AnyStringEnum = {[name: string]: string};

    /**
     * Given an enum type, yields a union of all its known enum values.
     */
    export type ValueOf<_Enum extends AnyStringEnum> = _Enum[keyof _Enum];

    /**
     * A collection of methods that set a particular `Fact` to `true` or
     * `false`.
     */
    export type SetterGroup<Fact extends string> = Record<Fact, () => void>;

    /**
     * Describes the extra methods that a `FactTable` can have.
     */
    export interface Settable<Fact extends string> {
        setTrue: SetterGroup<Fact>;
        setFalse: SetterGroup<Fact>;
    }

    /**
     * Generates a `SetterGroup` given an `_enum`, a `factTable` to which the
     * methods should be bound, and a `value` that the methods should use.
     */
    export function settersFor<_Enum extends AnyStringEnum>(
        _enum: _Enum,
        factTable: FactTable<ValueOf<_Enum>>,
        value: boolean,
    ): SetterGroup<ValueOf<_Enum>> {
        const methods = {} as SetterGroup<ValueOf<_Enum>>;
        for (const enumName of Enum.keys(_enum)) {
            const enumValue = _enum[enumName];
            methods[enumName] = () => factTable.set(enumValue, value);
        }
        return methods;
    }

    /**
     * Adds setter methods to the `factTable` using the given `_enum`, then
     * returns the augmented `factTable`.
     */
    export function withSetters<_Enum extends AnyStringEnum>(
        _enum: _Enum,
        factTable: FactTable<ValueOf<_Enum>>,
    ): FactTable<ValueOf<_Enum>> & Settable<ValueOf<_Enum> {
        return Object.assign(factTable, {
            setTrue: settersFor(_enum, factTable, true),
            setFalse: settersFor(_enum, factTable, false),
        });
    }
}

Note how the factTable function was rewritten. In order to create the correct methods, it must take the entire enum in addition to the initial conditions required by the unexported _FactTable class. This means the type Fact extends string is replaced by factTable.ValueOf<_Enum>.

In this particular version, we've exported the internal machinery for generating the additional methods, like settersFor and withSetters. In a formally published API, these need not be exported, and can be moved out of the namespace and into module level and made unexported.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment