Cheat Sheet

Mastering Type-Level Programming in TypeScript

Mastering Type-Level Programming in TypeScript

Cheat Sheet

Mastering Type-Level Programming in TypeScript


Type inference

// TypeScript automatically determines data types
let a = 1; // a: number
let b = “Hello”; // b: string
let c = [1, 2, 3]; // c: number[]
let d = { foo: [1, 2, 3] }; // d: { foo: number[] }

Pro tip

Type annotations are always required in function, method signatures, and in class definitions. Otherwise, they are only required where TypeScript doesn’t understand something or understands it incorrectly. The compiler option noImplicitAny helps to detect these places.

Const assertion

// The types determined by type inference are quite loose
let a = { foo: [1, 2, 3]};
// a: { foo: number[] }

// With “as const”, the types are made as strict as possible
let b = { foo: [1, 2, 3] } as const;
// b: { readonly foo: readonly [1, 2, 3] }

Type Alias

type Foo = number;
// “Foo” is an alias for “number”

type Bar = { x: number };
// “Bar” is a new object type

type Baz = (arg: number) => number;
// “Baz” is a new function type

Remember

A type alias for an object type is the same as an interface in almost all circumstances.

Declare keyword

// Type definition
type Foo = { x: number };
// Implies a variable “myFoo” of type “Foo”
declare var myFoo: Foo;
// Type check OK, but runtime error
console.log(myFoo.x); // “myFoo” does not really exist

// Type definition (function signature)
type Bar = (x: number) => number;
// Impersonates a function “myFunc” of type “Bar”
declare var myFunc: Bar;
// Type check OK, but runtime error
console.log(myFunc(1)); // “myFunc” doesn't really exist

WTF?

declare has no place in production code, but it's very handy for theoretical type experiments. It saves us from instantiating complex objects and keeps us away from TypeScript's smart type inference.

Interface

interface Foo { x: number }
// "Foo" is a new object type
 
interface Bar {
  (x: number): number;
}
// "Bar" is a new function type 

Pro tip

An interface with more than one call syntax à la (x: number): number describes the type of an overloaded function. It also works with typalias.

Function Overload

// Two call signatures, one implementation signature
function square(x: bigint): bigint; // Call signature 1
function square(x: number): number; // Call signature 2
function square(x: any): any { // Implementation signature
  return x * x;
}
 
let s1 = square(2n);  // s1: bigint (Signature 1)
let s2 = square(2);   // s2: number (Signature 2)
let s3 = square("2"); // Invalid 

Remember

Function overloading is the method of choice when a table of finite length can be constructed with signatures (parameters and return types) for a function. Generics and conditional types are intended for more complicated cases.

Structural Subtyping

// “Foo” and “Bar” describe the same object structure and
// are indistinguishable for TypeScript
type Foo = { x: number };
type Bar = { x: number };

declare var myFoo: Foo;
declare var myBar: Bar;
myFoo = myBar; // OK: “Foo” and “Bar” describe the same thing
myBar = myFoo; // OK: “Foo” and “Bar” describe the same thing

// “Baz” complements the structure of “Foo/Bar” and is therefore a
// subtype of “Foo/Bar”. It follows that “Foo/Bar” describe
// a substructure of “Baz” and are therefore supertypes of “Baz”.
type Baz = { x: number; y: string };

declare var myBaz: Baz;
myFoo = myBaz; // OK: “Baz” is a subtype of “Foo”
myBar = myBaz; // OK: “Baz” is a subtype of “Bar”
myBaz = myFoo; // NOT OK: “Foo” is a supertype of “Baz”
myBaz = myBar; // Not OK: “Bar” is the supertype of “Baz”

Remember

TypeScript's type system differentiates types only on the basis of their capabilities and structure (structural subtyping), not on the basis of their identity. Object types with the same structure are indistinguishable for the type system. Subtypes can be assigned to their supertypes, but not the other way around.

Declaration merging

interface Foo { x: number }
interface Foo { y: string }
// Type “Foo” is “{ x: number, y: string }”
// Multiple interfaces in the same scope are // merged
// Only works with interfaces

Readonly supertypes

// Normal object with normal array
type Foo = {
  values: string[];
}
 
// Readonly object with readonly array
type ReadonlyFoo = {
  readonly values: readonly string[];
}
 
// Normal objects are subtypes of their readonly variants,
// because they offer more functions in comparison
declare var normalObj: Foo;
declare var readonlyObj: ReadonlyFoo;
 
normalObj.values.push("A"); // OK
readonlyObj.values.push("B"); // Not OK ("readonly")
 
readonlyObj = normalObj;
// OK: "normal" is an extension of "readonly"
normalObj = readonlyObj;
// Not OK: “readonly” lacks features of “normal” 

Remember

Since a readonly variant of a given type T by definition offers fewer functions than T itself, a readonly type of T is always a supertype of T.

Tuple

// Type for arrays of fixed length with defined type per index
type Foo = [number, string];
declare var myFoo: Foo;

myFoo = [1, “Hello”]; // OK
myFoo = [“Hello”, 1]; // Not OK (wrong types per index)
myFoo = [1, “Hello”, 2]; // Not OK (“Foo” only has two places)

type At0 = Foo[0]; // At0 = number
type At1 = Foo[1]; // At0 = string
type At2 = Foo[2]; // Not OK (“Foo” is only two characters long)
type L = Foo[“length”]; // L = 2 (literal type)

Generic types

type Foo<T, U> = (T | U)[];
// “Foo” builds an array type from the types “T” and “U”
type ArrayOfStringsAndNumbers = Foo<string, number>;
// = Results in “(string | number)[]”

interface Bar<T> { x: T }
// “Bar” wraps a type “T” in an object type
type ObjectWithStringsForX = Bar<string>;
// = Resulting in “{ x: string }”

Key takeaway

You can think of generic types as type factory functions. While they are technically types, usable (instantiable) types are only created when they are “called” with type parameters. By way of comparison, JavaScript functions are also technically normal objects, but their only use case is to be called with parameters.

Generic types with defaults

type Foo<T = any> = { x: T };
// “T” is optional and becomes “any” if not specified

type F1 = Foo; // = { x: any }
type F2 = Foo<string>; // = { x: string }

Remember

Generics are really nothing more than parameters for types. The feature set is absolutely comparable to that of JavaScript value parameters for functions.

Generic types with conditions

type Foo<T extends object> = { x: T }
// “T” must be any object type
type A = Foo<{ x: number }>; // OK
type B = Foo<number>; // Error: “number != object”

type Bar<T extends any[]> = { x: T }
// “T” must be any array type
type C = Bar<number[]>; // OK
type D = Bar<number>; // Error: “number != Array”

Remember

extends in type parameters or generics always formulates a condition and is therefore a type for types, so to speak.

Generic types for functions

// Type parameters “T”, “U” and value parameters “a”, “b”
function toArray<T, U>(a: T, b: U): (T | U)[] {
  return [a, b];
}
 
// Type parameters explicitly specified
let a = toArray<string, number>("Hello", 1);
// a: (string | number)[]
 
// Type parameters determined by type inference
let b = toArray("Hello", 1);
// b: (string | number)[] 

Key insight

The toArray() function is, strictly speaking, four-argument: the two value parameters a and b are joined by the two type parameters T and U. Although both parameter pairs are related to each other to a certain extent, they still allow a relatively large degree of design freedom.

Const Generics

type Values = { values: readonly string [] };
 
function getValues<T extends Values>(
  input: T
): T["values"] {
  return input.values;
}
 
let a = { values: ["a", "b", "c"] };
// a: { values: string[] }
let b = { values: ["a", "b", "c"] } as const;
// b: { readonly values: readonly ["a", "b", "c"] }
 
let aResult1 = getValues(a); // string[] - OK!
let bResult1 = getValues(b); // string[] - information loss!
 
// New: -----------------↓
function getConstValues<const T extends Values>(
  input: T
): T["values"] {
  return input.values;
}  
 
let aResult2 = getConstValues(a); // string[] - OK!
let bResult2 = getConstValues(b); // readonly ["a", "b", "c"] -// OK! 

Union Type

type Foo = { x: number, y: boolean };
type Bar = { x: number, z: string };
type FooBar = Foo | Bar;
// “FooBar” describes the commonalities of “Foo” and “Bar”
// “FooBar” is a supertype of “Foo” and “Bar”. Therefore:

declare var myFoo: Foo;
declare var myBar: Bar;
const a: FooBar = myFoo; // OK: “Foo” is a subtype of “FooBar”
const b: FooBar = myBar; // OK: “Bar” is a subtype of “FooBar”

declare var myFooBar: FooBar;
const c: Foo = myFooBar; // Not OK: “Foo” is a supertype of “FooBar”
const d: Bar = myFooBar; // Not OK: “Bar” is a supertype of “FooBar”

declare var test: FooBar;

console.log(test.x); // OK: “x” is on “Foo” and “Bar”
console.log(test.y); // Not OK: “y” is missing “Bar” and therefore also// “FooBar”
console.log(test.z); // Not OK: “z” is missing “Foo” and therefore also// “FooBar”

Literal Types

type N1 = number; // a number
type N2 = 42; // the number “42”
type N3 = 23 | 42; // the numbers “23” and “42”

type S1 = string; // a string
type S2 = “A”; // the string “A”
type S3 = “A” | “B”; // the strings “A” and “B”

let a = “A”; // type inference: “string”
const b = “B”; // type inference: “B” (due to “const”)
let c = “C” as const; // type inference: “C” (due to “as const”)

Remember

Every type describes a set of possible values: number is the set of all numbers, string is the set of all strings, number | string is the set of all numbers plus the set of all strings. Literal types like 42 and “Hello” are sets of size 1 and describe specific subsets of their base types (in this case number and string, respectively).

Type Guard

type Car = { topSpeed: number };
type House = { floorArea: number };
 
// Function for duck typing returns Boolean
// with type information (“argName is TypeName”)
function isCar(x: unknown: x is Car {
  return typeof x === "object"
    && x !== null
    && "topSpeed" in x;
}
 
declare var myThing: Car | House; 
 
// Duck Typing per function!
if (isCar(myThing)) {
  // TypeScript knows that “myThing” is a “Car” here
  console.log(myThing.topSpeed); // OK
  console.log(myThing.floorArea); // not OK
} else {
  console.log(myThing.floorArea);
  // TypeScript knows that “myThing” must be “House” here
  console.log(myThing.topSpeed); // Not OK
  console.log(myThing.floorArea); // OK
} 

Remember

“Duck typing” (if something waddles like a duck and quacks like a duck, it is a duck) is a procedure in which the type of an object is inferred by examining the object. This approach is common in JavaScript. Type guards can be used to communicate the result of duck typing to TypeScript.

Assertion Signature

type Car = { topSpeed: number };
type House = { floorArea: number };
 
// Function for duck typing throws exceptions
// for non-matching types and thus provides
// type information (“asserts argName is TypeName”)
function assertCar(x: unknown): asserts x is Car {
  if (
    typeof x !== "object"
      || x === null
      || ! ("topSpeed" in x)
  ) {
    throw new TypeError("Not a Car!");
  }
}
 
declare var myThing: Car | House;
 
assertCar(myThing);
 
// TypeScript knows that “myThing” must be “Car” here,
// otherwise the program would have ended with an exception
// 
console.log(myThing.topSpeed); // OK
console.log(myThing.floorArea); // Not OK 

Discriminated Union

// Readonly String Literal Types act as a discriminant
type Car = { readonly type: "CAR", topSpeed: number };
type House = { readonly type: "HOUSE", floorArea: number };
 
// Union of object types with discriminant = // Discriminated Union
declare var myThing: Car | House;
 
// No Type Guard necessary!
if (myThing.type === "CAR") {
  // TypeScript knows that “myThing” is a “Car” here
  console.log(myThing.topSpeed); // OK
  console.log(myThing.floorArea); // not OK
} else {
  // TypeScript knows that “myThing” must be a “House” here
  console.log(myThing.topSpeed); // Not OK
  console.log(myThing.floorArea); // OK
} 

Remember

While a type guard performs duck typing, an if/else/switch on a discriminated union uses the discriminant as a signal for type narrowing. TypeScript does not have true algebraic data types.

Never

// Unions are sets
type A = string | number;
type B = boolean | number;
type C = A | B
// C is “string | number | boolean”

// The type “never” is an empty set
type D = C | never;
// “D” remains “string | number | boolean”

WTF?

The empty set never is like the number 0: not very useful on its own, but absolutely essential for advanced logic.

Template String Literals

// Type for strings that start with “A”
type StartsWithA = `A${string}`;
let a: StartsWithA = “A”; // OK
let b: StartsWithA = “Alpha”; // OK
let c: StartsWithA = “Foo”; // Not OK

// Equivalent to `A${string}` | `B${string}`
type StartsWithAOrB = `${“A” | “B”}${string}`;
let d: StartsWithAOrB = “A”; // OK
let e: StartsWithAOrB = “Beta”; // OK
let f: StartsWithAOrB = “Foo”; // Not OK

// Equivalent to “A_A” | “A_B” | “B_A” | “B_B”
type AB = `${“A” | “B”}_${“A” | “B”}`;
let g: AB = “A_B”; // OK
let h: AB = “A_A”; // OK
let i: AB = “B_”; // Not OK

String type manipulation utilities

type S1 = Uppercase<"Foo">;    // > "FOO"
type S2 = Lowercase<"Foo">;    // > "foo"
type S3 = Capitalize<"foo">;   // > "Foo"
type S4 = Uncapitalize<"FOO">; // > "fOO" 

Intersection Type

type Foo = { x: number, y: boolean };
type Bar = { x: number, z: string };
type FooBar = Foo & Bar;
// “FooBar” has all the capabilities of “Foo” and “Bar” (x, y, and z);
// “Foo” and “Bar” are both supertypes of “FooBar”. Therefore:

let myFoo: Foo = { x: 1, y: true };
let myBar: Bar = { x: 1, z: “Hello” };
let test1: FooBar = myFoo; // Not OK
let test2: FooBar = myBar; // Not OK
let test3: FooBar = { ...myFoo, ...myBar }; // OK

console.log(test3.x, test3.y, test3.z); // All OK

Remember

While the intersection type A & B produces a new subtype of A and B, the union type A | B produces a supertype of...