← Home

Notes on Advanced TypeScript

Source:

Generics

A generic type is a type that will only be known at call time. A generic is a stand-in for the type that will eventually be used.

We use a generic when…

  • we do not want to define the type of the argument that will be passed in, but,
  • we do want to use that future type once the argument has been passed in.

The generic captures and represents the type that was provided, so that this stand-in type can be used in a function, class or interface.

function identity<T>(x: T): T {
  return x;
}

const num = identity(1);
// `num` is inferred to be type of number
// because the arg 1 is of type number

The point of the identity() function is to take in and return out the exact same argument, but also to specify that the type of the argument passed in, named T, will be the type of the return value. In other words, the generic type variable <T> stands for the type to be passed into, and returned by, the function. Whichever type the argument is, the return type shall be the same. We introduce a generic in a function with the <T> before the parameter; we use the generic when annotating the function’s argument and return value.

When we call a function that relies on a generic, the typing may be implicit or explicit:

// explicit typing → type set per arg
identity<number>(3);
identity<string>("john");

// implicit typing → type inferred
identity("john");

By convention, the letter T is used for the first type variable, U for the second, etc., and K for each key (property) in an object, but we can call the generic whatever we want.

function identity<T, U>(arg1: T, arg2: U): Array<T, U> {
  return [arg1, arg2];
}

Classes can be annotated with generics:

class Container<T> {
  data: T;

  constructor(data: T) {
    this.data = data;
  }

  useContents(arg: T) {
    // ...
  }
}

const numberContainer = new Container<number>();

Interfaces can be annotated with generics:

interface Storage<T> {
  get(): T;
  set(value: T): void;
}

Generic constraints

A generic constraint, with the extends keyword, restricts the types that a generic accepts. We cna read extends as “has to be”.

For example, we define an interface Printable with a print() method.

interface Printable {
  print(): void;
}

And then we declare a function printHousesOrCars(), which takes in an array of entities whose type is generic but constrained to the Printable interface. What can pass in “whatever” (generic) but it “has to be” (constraint) a member of some type.

With the constrained generic <T extends Printable>, we ensure that whichever entity is passed in as an argument must have a type that satisfies the Printable interface.

function printHousesOrCars<T extends Printable>(arr: T[]) {
  arr.forEach((item) => {
    item.print();
    // `print()` is allowed - all Printables have a `print()` method!
  });
}

Conditional typing with generics

We can assign a type conditionally using a ternary expression and the extends keyword.

type Item<T> = {
  container: T extends string ? StringContainer : NumberContainer;
};

The extends syntax means “is a subset”, that is, if the generic type T is a subset of string, then the type for the container property is StringContainer. Otherwise, it is NumberContainer.

Another example:

function process<T extends number | string>(
  x: T
): T extends string ? string : number {
  // ...
}

If T is a subset of string, then the return type is string. Otherwise, the return type is number.

Combined with never, conditional typing with generics can also filter out types:

// constrain the generic to only allow for arrays
type ArrayFilter<T> = T extends any[] ? T : never;

// filter out non-arrays - `StringsOrNumbers` is string[] or number[]
type StringsOrNumbers = ArrayFilter<string | number | string[] | number[]>;

Generic with default type

A generic can default to a type:

class FruitBasket<T = Apple> {
  constructor(public fruitsbasket) {}

  add(fruit: T) {
    this.fruits.push(fruit);
  }

  eat() {
    this.fruits.pop();
  }
}

class Fruit {
  isFruit: true;
  constructor(public name: string) {}
}

class Apple extends Fruit {
  type: "Apple";
  constructor() {
    super("Apple");
  }
}

Types as sets

A type can be thought of as a set of values.

  1. Type Undefined → Set containing one element: the value undefined.
  2. Type Null → Set containing one element: the value null.
  3. Type Boolean → Set containing two elements: the values true and false.
  4. Type Number → Set containing all numbers, such as 1, 2, etc.
  5. Type String → Set containing all strings, such as "hi", "bye", etc.
  6. Type Object → Set containing all objects (including functions and arrays).

A set may contain all elements, a finite or an infinite number of elements, or no elements:

Set Example
Set containing all elements (universal set) unknown
Set containing an infinite number of elements string, number
Set containing an finite number of elements string literal
Set containing no elements (empty set) never

TypeScript terms can be thought of as having equivalents in set theory:

TypeScript term Equivalent in set theory
a value is assignable to a type an element is a member of a set
a type is assignable to another type a set is a subset of another set
an interface extends another interface a set is a subset of another set
type | type a union of two sets
type & type an intersection of two sets

In the example below, value "A" is assignable to type AB, i.e. value "A" is a member of set {"A", "B"}. By contrast, value "C" is not assignable to type AB, i.e. value "C" is not a member of set {"A", "B"}.

type AB = "A" | "B"; // set
const a: AB = "A"; // element in set

This means that, if a variable has a type, the values that can be assigned to that variable are elements in the set of that type. Out of all possible values, the values that are assignable to a variable have to be elements in the set of that type.

extends as a subset indicator

In interfaces, the keyword extends indicates that the extending interface is a subset of the original interface, i.e. it has all its typed properties and more.

// larger set
interface Person {
  name: string;
}

// smaller set
interface Employee extends Person {
  role: string;
  joined: Date;
}

In generics, extends constrains the generic type, which means that the extending set is a subset of the set represented by the generic type. The generic type is the universal set, a set containing every possible value, and extends reduces that universal set to a subset. In other words, extending a generic type with string means that only a value that is a member of string can be used as that generic type. Below, the value "a" is assignable to type string, which extends (i.e. is a subset of) the generic type K.

function getKey<K extends string>(value: any, key: K) {
  // string is a subset of K → "a" is assignable to string and therefore to K
}

Excess property checking

An interface can be interpreted in two different ways:

  • The interface describes the maximum shape of the object, so excess properties are disallowed.
  • The interface describes the minimum shape of the object, so excess properties are allowed.

Therefore, TypeScript…

  • disallows excess properties when the object is created and typed at the same time, but
  • allows excess properties is the object is typed after it was created.

Excess property checking disallows unknown properties on an object literal when assigned to a typed variable. That is, when you create an object and assign it on the spot to a variable having a declared type, TypeScript checks it has the properties of that type and no excess properties.

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}

// assigning object literal to typed variable
const room: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
};
// Error: Object literal may only specify known properties,
// and "elephant" does not exist in type "Room"

To allow for excess properties in an object, type the object after it was created.

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}

// assigning object literal to untyped variable
const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
};

// typing after the fact
const r: Room = obj;

Or instruct TypeScript to expect excess properties using an index signature:

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
  [otherOptions: string]: unknown;
}

Imports and exports

Namespace import

To import a module into a single variable, use import * as.

// module to be imported
export const CLIENT_ID = 8034;
export const CLIENT_NAME = "John Smith";
import * as client from "./client";

console.log(client.CLIENT_ID);
console.log(client.CLIENT_NAME);

Non-standard import

If you have a CommonJS module whose single export is a non-namespaced value, use the non-standard import.

To import a module that uses export = fn or module.exports = fn, use import module = require("module").

// module to be imported
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}
export = ZipCodeValidator;
import zip = require("./ZipCodeValidator");

let strings = ["Hello", "98052", "101"];

let validator = new zip();

strings.forEach((s) => {
  console.log(
    `"${s}" - ${validator.isAcceptable(s) ? "matches" : "does not match"}`
  );
});

This non-standard import is unneeded if the module to be imported uses export = { fn } or module.exports = { fn }.

Module scoping

If we do not declare any imports or exports in a file, TypeScript infers that the file is globally scoped. It does not need to be explicitly imported into another file to be used there.

The presence of a single import or export in a file changes how TypeScript deems it locally scoped or file-scoped.

Type guards

A type guard is a condition that narrows down a union type, which allows for better TypeScript analysis. For a union type, TypeScript offers only the properties and methods that in common between the types. To restore access to properties and methods that are not in common between the types, use a type guard to check for the type.

For a primitive value, use an if statement with the typeof operator.

if (typeof value === "string") {
  // `value` is type string
}

If value is a string | number union type, adding this type guard ensures that, inside the if block, TypeScript knows that value is type string and offers only string properties and methods.

For an object type, use an if statement with the instanceof operator.

if (collection instanceof Array) {
  // `collection` is type Array
}

If collection is a number[] | string union type, adding this type guard ensures that, inside the if block, TypeScript knows that collection is type Array and offers only array properties and methods.

User-defined type guard

A user-defined type guard is a custom function that determines if a type if an argument is a type. It is a function that returns a boolean and where the return type after the colon is specified as argument is type. As with a standard type guard, a user-defined type guard is intended to narrow down a type for better TypeScript analysis.

/**Determines if the argument is an HTMLInputElement
   based on whether it has a `"value"` property.*/
function isInputElement(elem: HTMLElement): elem is HTMLInputElement {
  return "value" in elem;
}

function getElementContent(elem: HTMLElement) {
  if (isInputElement(elem)) {
    // `el` is type HTMLInputElement
    return elem.value;
  }
  // `elem` is type HTMLElement
  return elem.textContent;
}

User-defined type guards, in turn, can be used filter out values that are not null when constructing a type:

function isNonNullable<T>(x: T | undefined | null): x is T {
  return x !== undefined && x !== null;
}

Mapped types

A mapped type is a type based on another type, through iteration over its constituents.

type Options = "option1" | "option2";

// mapped type
type Flags = { [O in Options]: boolean };

// spelled-out equivalent of mapped type
type Flags = {
  option1: boolean;
  option2: boolean;
};

A mapped type can also be based on the keys of its base type.

type Employee = {
  name: string;
  age: number;
};

// type based on `Employee` where every property is optional
type PartialEmployee = {
  [P in keyof Employee]?: Employee[P];
};

// type based on `Employee` where every property is read-only
type ReadonlyEmployee = {
  readonly [P in keyof Employee]: Employee[P];
};

// type based on `Employee` where every property can be null
type NullableEmployee = {
  [P in keyof Employee]: Employee[P] | null;
};

A mapped type can be generalized with generics:

type Partial<T> = {
  [K in keyof T]?: T[K];
};

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

Incidentally, many mapped types with generics are already built-in as utility types.

type PartialEmployee = Partial<Employee>;
type ReadonlyEmployee = Readonly<Employee>;
type NullableEmployee = Nullable<Employee>;

Record<K, T> constructs a type out of different sets of keys and values:

// type to be used for keys
type Employees = "john" | "james" | "william";

// type to be used for values
interface EmployeeData {
  position: string;
  salary: number;
}

type EmployeeLedger = Record<Employees, EmployeeData>;

const employeeLedger: EmployeeLedger = {
  john: { position: "manager", salary: 80000 },
  james: { position: "developer", salary: 60000 },
  william: { title: "intern", salary: 40000 },
};

Pick<T, K> constructs a type by picking out specific keys from a type.

interface EmployeeData {
  firstName: string; // to be picked for bio
  lastName: string; // to be picked for bio
  dateOfBirth: Date; // to be picked for bio
  position: string;
  salary: number;
}

type EmployeeBio = Pick<EmployeeData, "firstName" | "lastName" | "dateOfBirth">;

const johnBio: EmployeeBio = {
  firstName: "john",
  lastName: "smith",
  dateOfBirth: "10/10/1980",
};

Lookup types

The keyof operator grabs the keys of an interface:

interface Person {
  name: string;
  age: number;
  location: string;
}

type PersonKeys = keyof Person;

// spelled-out equivalent of lookup type with `keyof`
type PersonKeys = "name" | "age" | "location";

The extends keyword can be combined with the keyof operator:

// the second generic has to be a key of the first generic!
function getObjectProperty<T, K extends keyof T>(object: T, key: T) {
  return object[key];
}

const fruit = {
  name: "Apple",
  sweetness: 80,
};

getObjectProperty(fruit, "sweetness"); // → 80

Another lookup type is property access in a type, just like property access in an object, using square brackets:

type personName = Person["name"];

// spelled-out equivalent of lookup type with property access
type personName = string;

Index signature

We use an index signature to express the types of all the keys and values in an interface:

interface TranslationDictionary {
  [key: string]: string;
}

In TranslationDictionary, every key and every value is of type string.

const dictionary: TranslationDictionary = {
  Yes: "Sí",
  No: "No",
  Maybe: "Quizás",
};

We can also change the index signature to apply a change to every key or value. For example, readonly in an index signature prevents all keys from being reassigned.

interface Person {
  readonly [key: string]: string;
}

let person: Person = {
  name: "john",
};

person.name = "william"; // disallowed

Function overloads

Function overloads express the different type pairings of inputs and outputs to a function. In other words, a function may return different types based on the arguments passed in—and this relationship can be expressed with function overloads.

type Falsy = false | 0 | "" | null | undefined;
type Truthy<T> = T extends Falsy ? never : T;

/**
When the argument is falsy, foo returns {}
When the argument is truthy, foo returns { x: T }
When the argument is unknown, foo may return {} or { x: T }
*/
function foo(value: Falsy): {};
function foo<T>(value: Truthy<T>): { x: T };
function foo(value: any) {
  return !value ? {} : { x: value };
}

const res1 = foo(""); // inferred: `{}`
const res2 = foo(1); // inferred: `{x: number}`
const res3 = foo("a"); // inferred: `{x: string}`

Discriminating union

A discriminating union is a union of types or interfaces having at least one shared property with a unique type.

interface Fruit {
  type: "fruit"; // shared property with unique type
  name: string;
  color: string;
  getJuice: () => void;
}

interface Vegetable {
  type: "vegetable"; // shared property with unique type
  name: string;
  color: string;
  steam: () => void;
}

type Edible = Fruit | Vegetable;

function cook(ingredient: Edible) {
  if (food.type === "fruit") {
    // typechecker has narrowed `ingredient` down to `Fruit`
    food.juice();
  }
}

Odd operators

Non-null assertion operator

The non-null assertion operator ! asserts that a type is not null and not undefined. Since this is an assertion, we are overriding the typechecker.

let s = e!.name; // assert that e is not null and access name property

Nullish coalescing operator

The nullish coalescing operator ?? allows us to set a fallback value if a primary value is null or undefined.

result = myVar ?? "Fallback";
// if `myVar` is not null and not undefined, result is set to `myVar´
// if `myVar` is null or undefined, result is set to `"Fallback"´

Note that the || operator falls back to the right value if left is falsy (which includes false, 0, "", NaN etc.), whereas the ?? uses the right value if left is null or undefined.

Optional chaining operator

The chaining operator . allows us to access properties and methods in an object. The optional chaining operator ?. allows us to access properties and methods in an object without having to validate that each reference in the chain.

If the reference is valid, the property or method is accessed; if the reference is invalid, the access attempt returns undefined.

// valid reference
const result = {
  person: {
    getName() {
      return "john";
    },
  },
};

const name = result.person?.getName(); // `name` is set to `"john"`

// equivalent to...

let john = "";
if (result && result.person && result.person.getName) {
  john = result.person.getName();
}

// invalid reference
const name = result.person?.getFullName(); // `name` is set to `undefined`

Enums

Use enums for sets of constants that belong together:

enum Reply {
  Yes = "Yes",
  No = "No",
}

enum LogLevel {
  off = "off",
  info = "info",
  warn = "warn",
  error = "error",
}

Parameter modifiers

TypeScript offers optional parameters, default parameters and rest parameters.

Optional parameter

In JavaScript, every parameter is optional (i.e. omittable). When omitted, a parameter’s value is undefined.

In TypeScript, every parameter is required unless made optional (i.e. omittable) with a ?. Inside the function, an optional parameter will have the union type T | undefined and will be undefined when omitted.

function buildName(firstName: string, lastName?: string) {
  if (lastName) {
    return `${firstName} ${lastName}`;
  } else {
    return firstName;
  }
}

buildName("john"); // success
buildName("john", "smith"); // success

Compare this with explicitly typing a parameter with the union type T | undefined, in which case the parameter cannot be omitted—the parameter is not optional and therefore has to be passed in.

function buildName(firstName: string, lastName?: string) {
  if (lastName) {
    return `${firstName} ${lastName}`;
  } else {
    return firstName;
  }
}

buildName("john"); // error
buildName("john", "smith"); // success
buildName("john", undefined); // success

Default parameter

We can set a default value (i.e. fallback) for a parameter:

function buildName(firstName: string, lastName: string = "doe") {
  return firstName + " " + lastName;
}

buildName("john", "smith"); // → "john smith"
buildName("john"); // → "john doe"

Rest parameter

We can gather params into a single variable using ... as a prefix:

function buildName(firstName: string, ...restOfName: string[]) {
  // `firstName` points to "john"
  // `secondName` points to ["alexander", "smith"]
  return firstName + " " + restOfName.join(" ");
}

buildName("john", "alexander", "smith"); // → "john alexander smith"

The rest parameter might have also been called the “collect” parameter. The compiler will build an array of the arguments passed in with the name given after the ellipsis ..., allowing you to use it in your function.

tsconfig.json

The tsconfig.json file specifies the source files and compiler options for TS-to-JS transpilation.

Official docs on the tsconfig.json file

Source files

With the files property, source files can be defined one by one:

{
  "compilerOptions": {
    // ...
  },
  "files": ["file1.ts", "file2.ts"]
}

With the include and exclude properties, source files can be defined as a glob-like pattern:

{
  "compilerOptions": {
    // ...
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"]
}

The exclude property filters out the files in the include property.

The supported glob wildcards are:

  • * matches zero or more characters (excluding directory separators)
  • ? matches any one character (excluding directory separators)
  • **/ recursively matches any subdirectory

The files property and the include/exclude pair can be combined. If so, files takes priority over exclude.

Compiler options

Strict type checking

Enable strict type checking with strict, which enables:

  • noImplicitAny: If a type is not inferrable, it must be specified.
  • noImplicitThis: If the type of this cannot be inferred, it must be specified.
  • alwaysStrict: Use JavaScript’s strict mode directive.
  • strictNullChecks: null and undefined are standalone types and not part of others except any.
  • strictBindCallApply
  • strictFunctionTypes
  • strictPropertyInitialization: Properties (i.e. fields) in classes must be initialized.
Do not suppress errors!

For better type safety, do not to enable options starting with suppress-, such as suppressImplicitAnyIndexErrors and suppressExcessPropertyErrors.

Configuring the output

{
  "compilerOptions": {
    "module": "...", // output module framework
    "target": "..." // output JS version (syntax)
  }
}

Specifying directories

{
  "compilerOptions": {
    "rootDir": "...", // source directory for output tree
    "outDir": "..." // output directory
  }
}

Adjusting the transpilation process

{
  "compilerOptions": {
    "typeRoots": "...", // only include the type defs in these dirs
    "types": "..." // only include the type defs in these @types packages
  }
}

Working with TypeScript

Dependency management

The first dependency to consider is TypeScript itself. It is possible to install TypeScript system-wide, but this is generally a bad idea for two reasons:

  • There is no guarantee that you and your coworkers will always have the same version installed.
  • It adds a step to your project setup.

Make TypeScript a devDependency instead.

Put @types dependencies in devDependencies, not dependencies. If you need @types at runtime, then you may want to rework your process.

Testing

Install Jest, TS Jest and type declarations as dev dependencies:

npm i -D jest ts-jest @types/jest

Configure Jest to find tests in TypeScript, transpile them and run them:

package.json
{
  "jest": {
    "moduleFileExtensions": ["ts", "js"],
    "transform": {
      ".ts": "<rootDir>/node_modules/ts-jest/preprocessor.js"
    },
    "testRegex": ".*\\.spec\\.ts$"
  }
}

Or…

jest.config.js
module.exports = {
  testMatch: ["<rootDir>/tests/*.test.ts"],
  transform: {
    "^.+\\.ts$": "ts-jest",
  },
};

Add test commands:

package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "npm test -- --watch"
  }
}

Write a test:

test("it should work", () => {
  expect(true).toEqual(true);
});

Install Git hooks for pre-commit testing:

npm i -D husky

And add a pre-commit command:

package.json
{
  "scripts": {
    "precommit": "npm test"
  }
}

Source maps

Source maps map positions and symbols in a generated file back to the corresponding positions and symbols in the original source.

To tell TypeScript to generate one, set the sourceMap option in tsconfig.json:

{
  "compilerOptions": {
    "sourceMap": true
  }
}

Now when you run tsc, it generates two output files for each .ts file: a .js file and a .js.map file.

Type coverage

Because of the negative effects any types can have on type safety and developer experience, it ois a good idea to keep track of the number of them in your codebase. There are many ways to do this, including the type-coverage package.

npx type-coverage
9985 / 10117 98.69%