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.
Each of the individual techniques above are known TypeScript patterns built on top of existing JavaScript patterns.
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>;
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".
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.
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.
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 getundefined
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.
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 new
ed 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.
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 bytypescript-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();
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>;
}
}
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.
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.