Chapter 11. Classes

When TypeScript was released for the very first time in 2012, the JavaScript ecosystem and the features of the JavaScript language were not comparable to what we have today. TypeScript introduced many features not only in the form of a type system but also syntax, enriching an already existing language with possibilities to abstract parts of your code across modules, namespaces, and types.

One of these features was classes, a staple in object-oriented programming. TypeScript’s classes originally drew a lot of influence from C#, which is not surprising if you know the people behind both programming languages1. But they are also designed after concepts from the abandoned ECMAScript 4 proposals.

Over time, JavaScript gained much of the language features pioneered by TypeScript and others, classes, along with private fields, static blocks, and decorators are now part of the ECMAScript standard and have been shipped to language runtimes in the browser and the server.

This leaves TypeScript in a sweet spot between the innovation they brought to the language in the early days, and standards which is what the TypeScript team sees as a baseline for all upcoming features of the type system. While the original design is very close to what JavaScript ended up with, there are enough differences worth mentioning.

In this chapter, we look at how classes behave in TypeScript and JavaScript, what possibilities we have to express ourselves, and where the differences between the standard and the original design are. We look at keywords, types, and generics, and train an eye to spot what’s being added by TypeScript to JavaScript, and what JavaScript brings to the table on its own.

11.1 Choosing the Right Visibility Modifier

Problem

There are two flavors in TypeScript for property visibility and access. One through special keyword syntax — public, protected, private — and another one through actual JavaScript syntax, when properties start with a hash character. Which one should you choose?

Solution

Prefer JavaScript-native syntax as it has some implications at runtime that you don’t want to miss. If you rely on a complex setup that involves variations of visibility modifiers, stay with the TypeScript ones. They won’t go away.

Discussion

TypeScript’s classes have been around for quite a while, and while they draw huge inspiration from ECMAScript classes which followed a few years after, the TypeScript team also decided to introduce features that were useful and popular in traditional class-based object-oriented programming at the time.

One of those features were property visibility modifiers, also sometimes referred to as access modifiers. Visibility modifiers are special keywords you can put in front of members — properties and methods — to tell the compiler how they can be seen and accessed from other parts of your software.

Note

All visibility modifiers, as well as JavaScript private fields, work on methods as well as properties.

The default visibility modifier is public, which can be written explicitly or just omitted.

class Person {
  public name; // modifier public is optional
  constructor(name: string) {
    this.name = name;
  }
}

const myName = new Person("Stefan").name; // works

Another modifier is protected, limiting visibility to classes and subclasses.

class Person {
  protected name;
  constructor(name: string) {
    this.name = name;
  }
  getName() {
    // access works
    return this.name;
  }
}

const myName = new Person("Stefan").name;
//                                   ^
// Property 'name' is private and only accessible within
// class 'Person'.(2341)

class Teacher extends Person {
  constructor(name: string) {
    super(name);
  }

  getFullName() {
    // access works
    return `Professor ${this.name}`;
  }
}

protected access can be overwritten in derived classes to be public instead. Also, protected access prohibits accessing members from class references that are not from the same subclass. So while this works:

class Player extends Person {
  constructor(name: string) {
    super(name);
  }

  pair(p: Player) {
    // works
    return `Pairing ${this.name} with ${p.name}`;
  }
}

Using the base class or a different subclass won’t work:

class Player extends Person {
  constructor(name: string) {
    super(name);
  }

  pair(p: Person) {
    return `Pairing ${this.name} with ${p.name}`;
    //                                    ^
    // Property 'name' is protected and only accessible through an
    // instance of class 'Player'. This is an instance of
    // class 'Person'.(2446)
  }
}

The last visibility modifier is private, which only allows access from within the same class.

class Person {
  private name;
  constructor(name: string) {
    this.name = name;
  }
}

const myName = new Person("Stefan").name;
//                                   ^
// Property 'name' is protected and only accessible within
// class 'Person' and its subclasses.(2445)

class Teacher extends Person {
  constructor(name: string) {
    super(name);
  }

  getFullName() {
    return `Professor ${this.name}`;
    //                        ^
    // Property 'name' is private and only accessible
    // within class 'Person'.(2341)
  }
}

Visibility modifiers can also be used in constructors as a shortcut to define both properties and initialize them.

class Category {
  constructor(
    public title: string,
    public id: number,
    private reference: bigint
  ) {}
}

// transpiles to

class Category {
  constructor(title, id, reference) {
    this.title = title;
    this.id = id;
    this.reference = reference;
  }
}

With all the features described here, it has to be noted that TypeScript’s visibility modifiers are compile-time annotations that get erased after the compilation step. Oftentimes, entire property declarations get removed if they are not initialized via the class description but in the constructor, as we have seen in the last example.

They are also only valid during compile time checks, meaning that a private property in TypeScript will be fully accessible in JavaScript afterward, meaning that you can bypass the private access check by asserting your instances as any, or access them directly once your code has been compiled. They are also enumerable, which means that their names and values become visible when being serialized via JSON.stringify or Object.getOwnPropertyNames. In short: The moment they leave the boundaries of the type system they just behave like regular JavaScript class members.

Note

Next to visibility modifiers, it’s also possible to add readonly modifiers to class properties.

Since limited access to properties is not only a feature that is reasonable within a type system, ECMAScript has adopted a similar concept called private fields for regular JavaScript classes.

Instead of a visibility modifier, private fields actually introduce new syntax in form of a pound sign or hash in front of the member’s name.

Tip

Introducing a new syntax for private fields has resulted in heated debate within the community on the pleasance and aesthetics of the pound sign. Some participants even called them abominable. If this addition irritates you as well, it might help to think of the pound sign as a little fence that you put in front of the things you don’t want everybody to have access to. Suddenly, the pound sign syntax becomes a lot more pleasant.

The pound sign becomes a part of the property’s name, meaning that it also needs to be accessed with the sign in front of it.

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  // we can use getters!
  get name(): string {
    return this.#name.toUpperCase();
  }
}

const me = new Person("Stefan");
console.log(me.#name);
//              ^
// Property '#name' is not accessible outside
// class 'Person' because it has a private identifier.(18013)

console.log(me.name); // works

Private fields are JavaScript through and through, there is nothing the TypeScript compiler will remove and they retain their functionality — hiding information inside the class — even after the compilation step. The transpiled result — with the latest ECMAScript version as a target — looks almost identical to the TypeScript version, just without type annotations.

class Person {
  #name;

  constructor(name) {
    this.#name = name;
  }

  get name() {
    return this.#name.toUpperCase();
  }
}

Private fields can’t be accessed in runtime code, and they are also not enumerable, meaning that no information of their contents will be leaked in any way possible.

The problem is now that both private visibility modifiers and private fields exist in TypeScript. Visibility modifiers have been there forever and have more variety combined with protected members. Private fields on the other hand are as close to JavaScript as they can get, and with TypeScript’s goal to be a “JavaScript syntax for types”, they pretty much hit the spot when it comes to the long-term plans of the language. So which one should you choose?

First of all, no matter which modifier you choose, they both fulfil their goal of telling you at compile time when there’s property access where it shouldn’t be. This is the first feedback you get telling you that something might be wrong, and this is what we’re aiming for when we use TypeScript. So if you need to hide information from the outside, every tool does its job.

But when you look further, it again depends on your setting. If you already set up a project with elaborate visibility rules, you might not be able to migrate them to the native JavaScript version immediately. Also, the lack of protected visibility in JavaScript might be problematic for your goals. There is no need to change something if what you have already works and does the trick.

If you run into problems that the runtime visibility shows details you want to hide, if you depend on others using your code as a library and they should not be able to access all the internal information, then private fields are the way to go. They are well-supported in browsers and other language runtimes, and TypeScript comes with polyfills for older platforms.

11.2 Explicitly Defining Method Overrides

Problem

In your class hierarchy, you extend from base classes and override specific methods in sub-classes. When you refactor the base class, you might end up carrying around old and unused methods, because nothing tells you that the base class has changed.

Solution

Switch on the noImplicitOverride flag and use the override keyword to signal overrides.

Discussion

You want to draw shapes on a canvas. Your software is able to take a collection of points with x and y coordinates, and based on a specific render function it will draw either polygons, rectangles, or other elements on an HTML canvas.

You decide to go for a class hierarchy, where the base class Shape takes an arbitrary list of Point elements and draws lines between them. This class takes care of housekeeping through setters and getters but also implements the render function itself.

type Point = {
  x: number;
  y: number;
};

class Shape {
  points: Point[];
  fillStyle: string = "white";
  lineWidth: number = 10;

  constructor(points: Point[]) {
    this.points = points;
  }

  set fill(style: string) {
    this.fillStyle = style;
  }

  set width(width: number) {
    this.lineWidth = width;
  }

  render(ctx: CanvasRenderingContext2D) {
    if (this.points.length) {
      ctx.fillStyle = this.fillStyle;
      ctx.lineWidth = this.lineWidth;
      ctx.beginPath();
      let point = this.points[0];
      ctx.moveTo(point.x, point.y);
      for (let i = 1; i < this.points.length; i++) {
        point = this.points[i];
        ctx.lineTo(point.x, point.y);
      }
      ctx.closePath();
      ctx.stroke();
    }
  }
}

To use it, you create a 2D context from an HTML canvas element, create a new instance of Shape, and pass the context to the render function.

const canvas = document.getElementsByTagName("canvas")[0];
const ctx = canvas?.getContext("2d");

const shape = new Shape([
  { x: 50, y: 140 },
  { x: 150, y: 60 },
  { x: 250, y: 140 },
]);
shape.fill = "red";
shape.width = 20;

shape.render(ctx);

Now we want to use the established base class and derive subclasses for specific shapes, like rectangles. We keep the housekeeping methods and specifically override the constructor, as well as the render method.

class Rectangle extends Shape {
  constructor(points: Point[]) {
    if (points.length !== 2) {
      throw Error(`Wrong number of points, expected 2, got ${points.length}`);
    }
    super(points);
  }

  render(ctx: CanvasRenderingContext2D) {
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}

The usage of Rectangle is pretty much the same.

const rectangle = new Rectangle([
  {x: 130, y: 190},
  {x: 170, y: 250}
]);
rectangle.render(ctx);

As our software evolves, we inevitably change classes, methods, and functions, and somebody in our codebase will rename the render method to draw.

class Shape {
  // see above

  draw(ctx: CanvasRenderingContext2D) {
    if (this.points.length) {
      ctx.fillStyle = this.fillStyle;
      ctx.lineWidth = this.lineWidth;
      ctx.beginPath();
      let point = this.points[0];
      ctx.moveTo(point.x, point.y);
      for (let i = 1; i < this.points.length; i++) {
        point = this.points[i];
        ctx.lineTo(point.x, point.y);
      }
      ctx.closePath();
      ctx.stroke();
    }
  }
}

This is not a problem per se, but if we are not using the render method of Rectangle anywhere in our code, maybe because we publish this software as a library and didn’t use it in our tests, nothing tells us that the render method in Rectangle still exists, with no connection to the original class whatsoever.

This is why TypeScript allows you to annotate methods you want to override with the override keyword. This is a syntax extension from TypeScript and will be removed the moment TypeScript transpiles your code to JavaScript.

When a method is marked with the override keyword, TypeScript will make sure that a method of the same name and signature exists in the base class. If you rename render to draw, TypeScript will tell you that the method render wasn’t declared in the base class Shape.

class Rectangle extends Shape {
  // see above

  override render(ctx: CanvasRenderingContext2D) {
//         ^
// This member cannot have an 'override' modifier because it
// is not declared in the base class 'Shape'.(4113)
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}

This error is a great safeguard to make sure that renames and refactors don’t break your existing contracts.

Note

Even tough a constructor could be seen as an overridden method, its semantics are different and handled through other rules (e.g. making sure that you call super when instantiating a sub-class).

By switching on the noImplicitOverrides flag in your tsconfig.json, you can further ensure that you need to mark functions with the override keyword. Otherwise, TypeScript will throw another error.

class Rectangle extends Shape {
  // see above

  draw(ctx: CanvasRenderingContext2D) {
// ^
// This member must have an 'override' modifier because it
// overrides a member in the base class 'Shape'.(4114)
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}
Note

Techniques like implementing interfaces that define the basic shape of a class already provide a solid baseline to prevent you from running into problems like this. So, it’s good to see the override keyword and noImplictOverrides as an additional safeguard when creating class hierarchies.

When your software needs to rely on class hierarchies to work, using override together with noImplicitAny is a good way to ensure that you don’t forget anything. Class hierarchies, like any hierarchies, tend to grow complicated over time, so better take any safeguard you can get.

11.3 Describing Constructors and Prototypes

Problem

You want to instantiate subclasses of a specific abstract class dynamically, but TypeScript won’t allow you to instantiate abstract classes.

Solution

Describe your classes with the constructor interface pattern.

Discussion

If you use class hierarchies with TypeScript, the structural features of TypeScript might sometimes get in your way. Look at the following class hierarchy for instance, where we want to filter a set of elements based on different rules.

abstract class FilterItem {
  constructor(private property: string) {};
  someFunction() { /* ... */ };
  abstract filter(): void;
}

class AFilter extends FilterItem {
  filter() { /* ... */ }
}


class BFilter extends FilterItem {
  filter() { /* ... */ }
}

The FilterItem abstract class needs to be implemented by other classes. In this example AFilter and BFilter, both concretizations of FilterItem, which serves as a baseline for filters.

const some: FilterItem = new AFilter('afilter'); // ok

Things get interesting when we are not working with instances right off the bet. Let’s say we want to instantiate new filters based on some token we get from an AJAX call. To make it easier for us to select the filter, we store all possible filters in a map:

declare const filterMap: Map<string, typeof FilterItem>;

filterMap.set('number', AFilter);
filterMap.set('stuff', BFilter);

The map’s generics are set to a string (for the token from the backend), and everything that complements the type signature of FilterItem. We use the typeof keyword here to be able to add classes to the map, not objects. We want to instantiate them afterward, after all.

So far everything works as you would expect. The problem occurs when you want to fetch a class from the map and create a new object with it.

let obj: FilterItem;
// get the constructor
const ctor = filterMap.get('number');

if(typeof ctor !== 'undefined') {
  obj = new ctor();
//          ^
// cannot create an object of an abstract class
}

What a problem! TypeScript only knows at this point that we get a FilterItem back, and we can’t instantiate FilterItem. Abstract classes mix type information (type namespace) with an actual implementation (value namespace). As a first step, let’s just look at the types, what are we expecting to get back from filterMap. Let’s create an interface (or type alias) that defines how the shape of FilterItem should look like.

interface IFilter {
  new(property: string): IFilter;
  someFunction(): void;
  filter(): void;
}

declare const filterMap: Map<string, IFilter>;

Note the new keyword. This is a way for TypeScript to define the type signature of a constructor function. If we substitute the abstract class for an actual interface, lots of errors start appearing now. No matter where you put the implements IFilter command, no implementation seems to satisfy our contract:

abstract class FilterItem implements IFilter { /* ... */ }
// ^
// Class 'FilterItem' incorrectly implements interface 'IFilter'.
// Type 'FilterItem' provides no match for the signature
// 'new (property: string): IFilter'.

filterMap.set('number', AFilter);
//                      ^
// Argument of type 'typeof AFilter' is not assignable
// to parameter of type 'IFilter'. Type 'typeof AFilter' is missing
// the following properties from type 'IFilter': someFunction, filter

What’s happening here? Seems like neither the implementation nor the class itself seems to be able to get all the properties and functions we’ve defined in our interface declaration. Why?

JavaScript classes are special: They have not only one type we could easily define, but two types! The type of the static side, and the type of the instance side. It might get clearer if we transpile our class to what it was before ES6: a constructor function and a prototype:

function AFilter(property) { // this is part of the static side
  this.property = property;  // this is part of the instance side
}

// a function of the instance side
AFilter.prototype.filter = function() {/* ... */}

// not part of our example, but on the static side
Afilter.something = function () { /* ... */ }

One type to create the object. One type for the object itself. So let’s split it up and create two type declarations for it:

interface FilterConstructor {
  new (property: string): IFilter;
}

interface IFilter {
  someFunction(): void;
  filter(): void;
}

The first type FilterConstructor is the constructor interface. Here are all static properties, and the constructor function itself. The constructor function returns an instance: IFilter. IFilter contains type information of the instance side. All the functions we declare.

By splitting this up, our subsequent typings also become a lot clearer:

declare const filterMap: Map<string, FilterConstructor>; /* 1 */

filterMap.set('number', AFilter);
filterMap.set('stuff', BFilter);

let obj: IFilter;  /* 2 */
const ctor = filterMap.get('number');
if(typeof ctor !== 'undefined') {
  obj = new ctor('a');
}
  1. We add instances of type FilterConstructor to our map. This means we only can add classes that produce the desired objects.

  2. What we want in the end is an instance of IFilter. This is what the constructor function returns when being called with new.

Our code compiles again and we get all the auto-completion and tooling we desire. Even better: We are not able to add abstract classes to the map. Because they don’t produce a valid instance:

filterMap.set('notworking', FilterItem);
//                          ^
// Cannot assign an abstract constructor type to a
// non-abstract constructor type.

The constructor interface pattern is used throughout TypeScript and the standard library. To get an idea, look at the ObjectContructor interface from lib.es5.d.ts.

11.4 Using Generics in Classes

Problem

TypeScript generics are designed to be inferred a lot, but in classes, this doesn’t always work.

Solution

Explicitly annotate generics at instantiation if you can’t infer them from your parameters, otherwise, they default to unknown and accept a broad range of values. Use generic constraints and default parameters for extra safety.

Discussion

Classes also allow for generics. Instead of only being able to add generic type parameters to functions, we can also add generic type parameters to classes. While generic type parameters at class methods are only valid in function scope, generic type parameters for classes are valid for the entirety of a class.

Let’s create a collection, a simple wrapper around an array with a restricted set of convenience functions. We can add T to the class definition of Collection and reuse this type parameter throughout the entire class.

class Collection<T> {
  items: T[];
  constructor() {
    this.items = [];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

With that, we are able to explicitly substitute T with a generic type annotation, e.g. allowing a collection of only numbers, or only strings.

const numbers = new Collection<number>();
numbers.add(1);
numbers.add(2);

const strings = new Collection<string>();
strings.add("Hello");
strings.add("World");

We as developers are not required to explicitly annotate generic type parameters. TypeScript usually tries to infer generic types from usage. If we forget to add a generic type parameter, TypeScript falls back to unknown, allowing us to basically add everything.

const unknowns = new Collection();
unknowns.add(1);
unknowns.add("World");

Let’s stay at this point for a second. TypeScript is very honest with us. The moment we construct a new instance of Collection, we don’t know what the type of our items is. unknown is the most accurate depiction of the collection’s state. And it comes with all the downsides of it: We can add anything, and we need to do type checks every time we retrieve a value. While TypeScript does the only thing possible at this point, we might want to do better. A concrete type for T is mandatory for Collection to properly work.

Let’s see if we can rely on inference. TypeScript’s inference on classes works just like it does on functions. If there is a parameter of a certain type, TypeScript will take this type and substitute the generic type parameter with it. Classes are designed to keep state, and state changes throughout their use. The state also defines our generic type parameter T. To correctly infer T, we need to require a parameter at construction, maybe an initial value.

class Collection<T> {
  items: T[];
  constructor(initial: T) {
    this.items = [initial];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

// T is number!
const numbersInf = new Collection(0);
numbersInf.add(1);

This works, but leaves a lot to be desired for our API design. What if we don’t have initial values? While other classes might have parameters that can be used for inference, this might not make a lot of sense for a collection of various items.

For Collection, it is absolutely essential to providing a type through annotation. The only way left is to ensure we don’t forget to add an annotation. To achieve this, we can make sure of TypeScript’s generic default parameters and the bottom type never.

class Collection<T = never> {
  items: T[];
  constructor() {
    this.items = [];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

We set the generic type parameter T to default to never, which adds some very interesting behavior to our class. T still can be explicitly substituted with every type through annotation, working just as before, but the moment we forget an annotation the type is not unknown, it’s never. Meaning that no value is compatible with our collection, resulting in many errors the moment we try to add something.

const nevers = new Collection();
nevers.add(1);
//     ^
// Argument of type 'number' is not assignable
// to parameter of type 'never'.(2345)
nevers.add("World");
//     ^
// Argument of type 'string' is not assignable
// to parameter of type 'never'.(2345)

A little fallback that makes the usage of our generic classes a lot safer.

11.5 Deciding When to Use Classes or Namespaces

Problem

TypeScript offers a lot of syntax for OO concepts like namespaces, or static and abstract classes. Those features don’t exist in JavaScript, so what should you do?

Solution

Stick with namespace declarations for additional type declarations, avoid abstract classes when possible, and prefer ECMAScript modules instead of static classes.

Discussion

One thing we see a lot from people who worked a lot with traditional OO programming languages like Java or C# is their urge to wrap everything inside a class. In Java, you don’t have any other options as classes are the only way to structure code. In JavaScript (and thus: TypeScript) there are plenty of other possibilities that do what you want without any extra steps. One of those things is static classes or classes with static methods.

// Environment.ts

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

While this works and is even — sans type annotations — valid JavaScript, it’s way too much ceremony for something that can easily be just plain, boring functions:

// Environment.ts
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

The interface for your users is exactly the same. You can access module scope variables just the way you would access static properties in a class, but you have them module-scoped automatically. You decide what to export and what to make visible, not some TypeScript field modifiers. Also, you don’t end up creating an Environment instance that doesn’t do anything.

Even the implementation becomes easier. Check out the class version of variables():

export default class Environment {
  private static variableList: string[] = [];
  static variables(): string[] {
    return this.variableList;
  }
}

As opposed to the module version:

const variableList: string = []

export function variables(): string[] {
  return variableList;
}

No this means less to think about. As an added benefit, your bundlers have an easier time doing tree-shaking, so you end up only with the things you actually use:

// Only the variables function and variableList
// end up in the bundle
import { variables } from "./Environment";

console.log(variables());

That’s why a proper module is always preferred to a class with static fields and methods. That’s just an added boilerplate with no extra benefit.

As with static classes, I see people with a Java or C# background clinging on to namespaces. Namespaces are a feature that TypeScript introduced to organize code long before ECMAScript modules were standardized. They allowed you to split things across files, merging them again with reference markers.

// file users/models.ts
namespace Users {
  export interface Person {
    name: string;
    age: number;
  }
}

// file users/controller.ts

/// <reference path="./models.ts" />
namespace Users {
  export function updateUser(p: Person) {
    // do the rest
  }
}

Back then, TypeScript even had a bundling feature. It should still work to this day. But as said, this was before ECMAScript introduced modules. Now with modules, we have a way to organize and structure code that is compatible with the rest of the JavaScript ecosystem. So that’s a plus.

So what do we need namespaces for? Namespaces are still valid if you want to extend definitions from a third-party dependency, e.g. that lives inside node modules. For example, if you want to extend the global JSX namespace and make sure img elements feature alt texts:

declare namespace JSX {
  interface IntrinsicElements {
    "img": HTMLAttributes & {
      alt: string;
      src: string;
      loading?: 'lazy' | 'eager' | 'auto';
    }
  }
}

Or if you want to write elaborate type definitions in ambient modules. But other than that? There is not much use for it anymore.

Namespaces wrap your definitions into an Object. Writing something like this:

export namespace Users {
  type User = {
    name: string;
    age: number;
  };

  export function createUser(name: string, age: number): User {
    return { name, age };
  }
}

emits something very elaborate:

export var Users;
(function (Users) {
    function createUser(name, age) {
        return {
            name, age
        };
    }
    Users.createUser = createUser;
})(Users || (Users = {}));

This not only adds cruft but also keeps your bundlers from tree-shaking properly! Also using them becomes a bit wordier:

import * as Users from "./users";

Users.Users.createUser("Stefan", "39");

Dropping them makes things a lot easier. Stick to what JavaScript offers you. Not using namespaces outside of declaration files makes your code clear, simple, and tidy.

Last, but not least, there are abstract classes. Abstract classes are a way to structure a more complex class hierarchy where you pre-define some behavior, but leave the actual implementation of some features to classes that extend from your abstract class.

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }

  abstract move(): string;
}

class Human extends Lifeform {
  move() {
    return "Walking, mostly...";
  }
}

It’s for all sub-classes of Lifeform to implement move. This is a concept that exists in basically every class-based programming language. The problem is, JavaScript isn’t traditionally class-based. For example, an abstract class like below generates a valid JavaScript class, but is not allowed to be instantiated in TypeScript:

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

const lifeform = new Lifeform(20);
//               ^
// Cannot create an instance of an abstract class.(2511)

This can lead to some unwanted situations if you’re writing regular JavaScript but rely on TypeScript to provide you the information in form of implicit documentation. E.g. if a function definition looks like this:

declare function moveLifeform(lifeform: Lifeform);
  • You or your users might read this as an invitation to pass a Lifeform object to moveLifeform. Internally, it calls lifeform.move().

  • Lifeform can be instantiated in JavaScript, as it is a valid class

  • The method move does not exist in Lifeform, thus breaking your application!

This is due to a false sense of security. What you actually want is to put some pre-defined implementation in the prototype chain, and have a contract that definitely tells you what to expect:

interface Lifeform {
  move(): string;
}

class BasicLifeForm {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

class Human extends BasicLifeForm implements Lifeform {
  move() {
    return "Walking";
  }
}

The moment you look up Lifeform, you can see the interface and everything it expects, but you hardly run into a situation where you instantiate the wrong class by accident.

With everything said about when to not use classes and namespaces, when should you use them: Every time you need multiple instances of the same object, where the internal state is paramount to the functionality of the object.

11.6 Writing Static Classes

Problem

Class-based object-oriented programming taught you to use static classes for certain features, but you wonder how those principles are supported in TypeScript.

Solution

Traditional static classes don’t exist in TypeScript, but TypeScript has static modifiers for class members for several purposes.

Discussion

Static classes are classes that can’t be instantiated into concrete objects. Their purpose is to contain methods and other members which exist once, and are the same when being accessed from various points in your code. Static classes are necessary for programming languages that only have classes as their means of abstraction, like Java or C#. In JavaScript, and subsequently TypeScript, there are many more means to express ourselves.

In TypeScript, we can’t declare classes to be static, but we can define static members on classes. The behavior is what you’d expect: The method or property is not part of an object but can be accessed from the class itself.

As we have seen in Recipe 11.5, classes with only static members are an anti-pattern in TypeScript. Functions exist, you can keep state per module. A combination of exported functions and module-scoped entries is usually the way to go.

// Anti-Pattern
export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Better: Module-scoped functions and variables
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

But there is still use for static parts of a class. We established in Recipe 11.3 that a class consists of static members and dynamic members. The constructor is part of the static features of a class, and properties, and methods are part of the dynamic features of a class. With the static keyword we can add to those static features.

Let’s think of a class called Point that describes a point in a two-dimensional space. It has x and y coordinates, and we create a method that calculates the distance between this point and another one.

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }
}

const a = new Point(0, 0);
const b = new Point(1, 5);

const distance = a.distanceTo(b);

This is good behavior, but the API might feel a bit weird if we’d choose a starting point and end point, especially since the distance is the same no matter which one is the first. A static method on Point gets rid of the order, and we have a nice distance method that takes two arguments.

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  static distance(p1: Point, p2: Point): number {
    return p1.distanceTo(p2);
  }
}

const a = new Point(0, 0);
const b = new Point(1, 5);

const distance = Point.distance(a, b);

A similar version using the constructor function/prototype pattern that was used pre-ECMAScript classes in JavaScript would look like this:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.distanceTo = function(p) {
  const dx = this.x - p.x;
  const dy = this.y - p.y;
  return Math.sqrt(dx * dx + dy * dy);
}

Point.distance = function(a, b) {
  return a.distanceTo(b);
}

As we did in Recipe 11.3, we can easily see which parts are static and which parts are dynamic. Everything that is in the prototype belongs to the dynamic parts. Everything else is static.

But classes are not only syntactic sugar to the constructor function/prototype pattern. With the inclusion of private fields, which are absent in regular objects, we can do something that is actually related to classes and their instances.

If we want to for example hide the distanceTo method because it might be confusing, and we’d prefer our users to use the static method instead, a simple private modifier in front of distanceTo makes it inaccessible from the outside, but still keeps it accessible from within static members.

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  #distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  static distance(p1: Point, p2: Point): number {
    return p1.#distanceTo(p2);
  }
}

The visibility also goes in the other direction. Let’s say you have a class that represents a certain Task in your system, and you want to limit the number of existing tasks to a certain number.

We use a static private field called nextId which we start at 0, and we increase this private field with every constructed instance Task. Should we reach 100, we throw an error.

class Task {
  static #nextId = 0;
  #id: number;

  constructor() {
    if (Task.#nextId > 99) {
      throw "Max number of tasks reached";
    }
    this.#id = Task.#nextId++;
  }
}

If we want to limit the number of instances by some dynamic value from a back-end, we can use a static instantiation block, that fetches this data and updates the static private fields accordingly.

type Config = {
  instances: number;
};

class Task {
  static #nextId = 0;
  static #maxInstances: number;
  #id: number;

  static {
    fetch("/available-slots")
      .then((res) => res.json())
      .then((result: Config) => {
        Task.#maxInstances = result.instances;
      });
    }

  constructor() {
    if (Task.#nextId > Task.#maxInstances) {
      throw "Max number of tasks reached";
    }
    this.#id = Task.#nextId++;
  }
}

Other than fields in instances, TypeScript at the time of writing does not check if static fields are instantiated. If we e.g. load the number of available slots form a back-end asynchronously, we have a certain timeframe where we can construct instances but have no check if we reached our maximum.

So, even if there is no construct of a static class in TypeScript, and static-only classes are considered an anti-pattern, there might be a good use for static members in many situations.

11.7 Working with Strict Property Initialization

Problem

Classes keep state, but nothing tells you if this state is being initialized.

Solution

Activate strict property initialization by setting strictPropertyInitialization to true in your tsconfig.

Discussion

Classes can be seen as code templates for creating objects. You define properties and methods, and only through instantiation do actual values get assigned. TypeScript classes take basic JavaScript classes and enhance them with more syntax to define types. For example, TypeScript allows you to define the properties of the instance pretty much in a type- or interface-like manner.

type State = "active" | "inactive";

class Account {
  id: number;
  userName: string;
  state: State;
  orders: number[];
}

However, this notation only defines the shape, it doesn’t set any concrete values, yet. When being transpiled to regular JavaScript, all those properties are erased, they only exist in the type namespace.

This notation is arguably very readable and gives you as a developer a good idea of what properties to expect. But there is no guarantee that these properties actually exist. If we don’t initialize them, everything is either missing or undefined.

TypeScript has safety guards for this. With the strictPropertyInitialization flag being set to true in your tsconfig.json, TypeScript will make sure that all properties you’d expect are actually initialized when creating a new object from your class.

Note

strictPropertyInitialization is part of TypeScript’s strict mode. If you set strict to true in your tsconfig — which you should — you also activate strict property initialization.

Once activated, TypeScript will greet you with many red squiggly lines.

class Account {
  id: number;
// ^ Property 'id' has no initializer and is
// not definitely assigned in the constructor.(2564)
  userName: string;
// ^ Property 'userName' has no initializer and is
// not definitely assigned in the constructor.(2564)
  state: State;
// ^ Property 'state' has no initializer and is
// not definitely assigned in the constructor.(2564)
  orders: number[];
// ^ Property 'orders' has no initializer and is
// not definitely assigned in the constructor.(2564)
}

Beautiful! Now it’s up to us to make sure that every property will receive a value. There are multiple ways to do this. If we look at the Account example, we can define some constraints or rules, if our application’s domain allows us to do so.

  1. id and userName need to be set, they control the communication to our backend and are necessary for display.

  2. state also needs to be set, but it has a default value of active. Usually, accounts in our software are active, unless being set intentionally to inactive.

  3. orders is an array that contains order IDs, but what if we haven’t ordered anything? An empty array works just as well, or maybe we set orders to not be defined yet.

Given those constraints, we already can rule out two errors. We set state to be active by default, and we make orders optional. There’s also the possibility to set orders to be of type number[] | undefined, which is the same thing as optional.

class Account {
  id: number; // still errors
  userName: string; // still errors
  state: State = "active"; // ok
  orders?: number[]; // ok
}

The other two properties still throw errors. By adding a constructor and initializing these properties, we rule out the other errors as well.

class Account {
  id: number;
  userName: string;
  state: State = "active";
  orders?: number[];

  constructor(userName: string, id: number) {
    this.userName = userName;
    this.id = id;
  }
}

That’s it, a proper TypeScript class! TypeScript also allows for a constructor shorthand, where you can turn constructor parameters into class properties with the same name and value by adding a visibility modifier like public, private, or protected to it. It’s a convenient feature that gets rid of a lot of boilerplate code. It’s important that you don’t define the same property in the class shape.

class Account {
  state: State = "active";
  orders?: number[];

  constructor(public userName: string, public id: number) {}
}

If you look at the class right now, you see that we rely only on TypeScript features. The transpiled class, the JavaScript equivalent, looks a lot different.

class Account {
  constructor(userName, id) {
    this.userName = userName;
    this.id = id;
    this.state = "active";
  }
}

Everything is in the constructor, because the constructor defines an instance.

Warning

While TypeScript shortcuts and syntax for classes seem nice, be careful how much you buy into. TypeScript switched gears in recent years to be mostly a syntax extension for types on top of regular JavaScript, but their class features which exist for many years now are still available and add different semantics to your code than you’d usually expect. If you lean towards your code being “JavaScript with Types”, be careful when you venture into the depths of TypeScript class features.

Strict property initialization also understands complex scenarios, like setting the property within a function that is being called via the constructor. It also understands that an async class might leave your class with a potentially uninitialized state.

Let’s say you only want to initialize your class via an id property and fetch the userName from a backend. If you do the async call within your constructor and set userName after the fetch call is complete, you still get strict property initialization errors.

type User = {
  id: number;
  userName: string;
};

class Account {
  userName: string;
// ^ Property 'userName' has no initializer and is
// not definitely assigned in the constructor.(2564)
  state: State = "active";
  orders?: number[];

  constructor(public id: number) {
    fetch(`/api/getName?id=${id}`)
      .then((res) => res.json())
      .then((data: User) => (this.userName = data.userName ?? "not-found"));
  }
}

And it’s true! Nothing tells you that the fetch call will be successful, and even if you catch errors and make sure that the property will be initialized with a fallback value, there is a certain amount of time when your object has an uninitialized userName state.

There are a few things you can do to get around this. One pattern that is quite nice is having a static factory function that works asynchronously, where you get the data first and then call a constructor which expects both properties.

class Account {
  state: State = "active";
  orders?: number[];

  constructor(public id: number, public userName: string) {}

  static async create(id: number) {
    const user: User = await fetch(`/api/getName?id=${id}`).then((res) =>
      res.json()
    );
    return new Account(id, user.userName);
  }
}

This allows both objects to be instantiated in a non-async context if you have access to both properties, or within an async context where you only have id available. We switch responsibilities and remove async from the constructor entirely

Another technique is to simply ignore the uninitialized state. What if the state of userName is totally irrelevant to your application, and you only want to access it when possible? Use the definite assignment assertion — an exclamation mark — to tell TypeScript that you will treat this property as initialized.

class Account {
  userName!: string;
  state: State = "active";
  orders?: number[];

  constructor(public id: number) {
    fetch(`/api/getName?id=${id}`)
      .then((res) => res.json())
      .then((data: User) => (this.userName = data.userName));
  }
}

The responsibility is now in your hands, and with the exclamation mark we have TypeScript-specific syntax we can qualify as unsafe operation, runtime errors included.

11.8 Working with this Types in Classes

Problem

You extend from base classes to re-use functionality, and your methods have signatures that refer to an instance of the same class. You want to make sure that no other sub-classes are getting mixed in your interfaces, but you don’t want to override methods just to change the type.

Solution

Use this as type instead of the actual class type.

Discussion

In this example, we want to model a bulletin board software’s different user roles using classes. We start with a general User class that is identified by its user ID and has the ability to open threads.

class User {
  #id: number;
  static #nextThreadId: number;

  constructor(id: number) {
    this.#id = id;
  }

  equals(user: User): boolean {
    return this.#id === user.#id;
  }

  async openThread(title: string, content: string): Promise<number> {
    const threadId = User.#nextThreadId++;
    await fetch("/createThread", {
      method: "POST",
      body: JSON.stringify({
        content,
        title,
        threadId,
      }),
    });
    return threadId;
  }
}

This class also contains an equals method. Somewhere in our codebase, we need to make sure that two references to users are the same, and since we identify users by their ID, we can easily compare numbers.

User is the base class of all users, so if we add roles with more privileges, we can easily inherit from the base User class. For example, Admin, has the ability to close threads, and stores a set of other privileges that we might use in other methods.

Note

There is much debate in the programming community if inheritance is a technique better left to be ignored since its benefits hardly outweigh its pitfalls. Nevertheless, some parts of JavaScript rely on inheritance, for example Web Components.

Since we inherit from User, we don’t need to write another openThread method, and we can re-use the same equals method since all administrators are also users.

class Admin extends User {
  #privileges: string[];
  constructor(id: number, privileges: string[] = []) {
    super(id);
    this.#privileges = privileges;
  }

  async closeThread(threadId: number) {
    await fetch("/closeThread", {
      method: "POST",
      body: "" + threadId,
    });
  }
}

After setting up our classes, we can create new objects of type User and Admin by instantiating the right classes. We can also call the equals method to compare if two users might be the same.

const user = new User(1);
const admin = new Admin(2);

console.log(user.equals(admin));
console.log(admin.equals(user));

There’s one thing that bothersome, though: The direction of comparison. Of course, comparing two numbers is commutative, it shouldn’t matter if we compare a user to an admin, but if we think about the surrounding classes and sub-types, there is some room for improvement:

  1. It’s ok to check if a user equals an admin, because it might gain privileges.

  2. It’s doubtful if we want to check if an admin equals a user, because the broader super-type has less information.

  3. If we have another sub-class of Moderator which is adjacent to Admin, we definitely don’t want to be able to compare them as they don’t share properties outside the base class.

Still, in the way equals is developed right now, all comparisons would just work. We can work around this by changing the type of what we want to compare. We annotated the input parameter with User first, but in reality, we want to compare with another instance of the same type. There is a type for that, and it is called this.

class User {
  // ...

  equals(user: this): boolean {
    return this.#id === user.#id;
  }
}

This is different from the erasable this parameter we know from functions, which we learned about in Recipe 2.7, as the this parameter type allows us to set a concrete type for the this global variable within the scope of a function. The this type is a reference to the class where the method is located. And it changes depending on the implementation. So if we annotate a user with this in User, it becomes an Admin in the class that inherits from User, or a Moderator, and so on. With that, admin.equals expects another Admin class to be compared to, otherwise, we get an error.

console.log(admin.equals(user));
//                       ^
// Argument of type 'User' is not assignable to parameter of type 'Admin'.

The other way around still works. Since Admin contains all properties from User (it’s a sub-class, after all), we can easily compare user.equals(admin).

this types can also be used as return types. Take a look at this OptionBuilder, which implements the builder pattern.

class OptionBuilder<T = string | number | boolean> {
  #options: Map<string, T> = new Map();
  constructor() {}

  add(name: string, value: T): OptionBuilder<T> {
    this.#options.set(name, value);
    return this;
  }

  has(name: string) {
    return this.#options.has(name);
  }

  build() {
    return Object.fromEntries(this.#options);
  }
}

It’s a soft wrapper around a Map, which allows us to set key/value pairs. It has a chainable interface, which means that after each add call, we get the current instance back, allowing us to do add call after add call. Note that we annotated the return type with OptionBuilder<T>.

const options = new OptionBuilder()
  .add("deflate", true)
  .add("compressionFactor", 10)
  .build();

We are now creating a StringOptionBuilder which inherits from OptionBuilder and sets the type of possible elements to string. We also add a safeAdd method with checks if a certain value is already set before it is written, so we don’t override previous settings.

class StringOptionBuilder extends OptionBuilder<string> {
  safeAdd(name: string, value: string) {
    if (!this.has(name)) {
      this.add(name, value);
    }
    return this;
  }
}

When we start using the new builder, we see that we can’t reasonably use safeAdd if we have an add as the first step.

const languages = new StringOptionBuilder()
  .add("en", "English")
  .safeAdd("de", "Deutsch")
// ^
// Property 'safeAdd' does not exist on type 'OptionBuilder<string>'.(2339)
  .safeAdd("de", "German")
  .build();

TypeScript tells us that safeAdd does not exist on type OptionBuilder<string>. Where has this function gone? The problem is that add has a very broad annotation. Of course StringOptionBuilder is a sub-type of OptionBuilder<string>, but with the annotation, we lose the information on the narrower type. The solution? Use this as return type.

class OptionBuilder<T = string | number | boolean> {
  // ...

  add(name: string, value: T): this {
    this.#options.set(name, value);
    return this;
  }
}

The same effect happens as with the previous example. In OptionBuilder<T>, this becomes OptionBuilder<T>. In StringBuilder, this becomes StringBuilder. If you return this and leave out the return type annotation at all, this becomes the inferred return type. So using this explicitly depends on your preference (see Recipe 2.1).

11.9 Writing Decorators

Problem

You want to log the execution of your methods for your telemetry, but adding manual logs to every method is cumbersome.

Solution

Write a class method decorator called log that you can annotate your methods with.

Discussion

The Decorator design pattern has been described in the renowned book Design Patterns: Elements of Reusable Object-Oriented Software 2 and describes a technique where we can decorate classes and methods to dynamically add or overwrite certain behavior.

What started as a naturally emerging design pattern in object-oriented programming has become so popular that programming languages which feature object-oriented aspects have added decorators as a language feature with a special syntax. You can see forms of it in Java (called annotations) or C# (called attributes), and also in JavaScript.

The ECMAScript proposal for decorators has been in proposal hell for quite a while but has reached stage 3 (ready for implementation) sometime around 2022. And just with all features reaching stage 3, TypeScript is one of the first tools to pick up the new specification.

Warning

Decorators have existed in TypeScript for a long time under the experimentalDecorators compiler flag. With TypeScript 5.0, the native ECMAScript decorator proposal is fully implemented and available without a flag. The actual ECMAScript implementation differs fundamentally from the original design, and if you developed decorators prior to TypeScript 5.0, they won’t work with the new specification. Note that a switched-on experimentalDecorators flag turns off the ECMAScript native decorators. Also, in regards to types, lib.decorators.d.ts contains all type information for the ECMAScript native decorators, while types in lib.decorators.legacy.d.ts contain old type information. Make sure your settings are correct and make sure that you don’t consume types from the wrong definition file.

Decorators allow us to decorate almost anything in a class. For this example, we want to start with a method decorator that allows us to log the execution of method calls.

Decorators are described as functions with a value and a context, both depending on the type of class element that you want to decorate. Those decorator functions return another function that will be executed before your own method (or before field initialization, or before an accessor call, etc.).

A simple log decorator for methods could look like this.

function log(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) {
    console.log(`calling ${context.name.toString()}`);
    return value.call(this, ...args);
  };
}

class Toggler {
  #toggled = false;

  @log
  toggle() {
    this.#toggled = !this.#toggled;
  }
}

const toggler = new Toggler();
toggler.toggle();

The log function follows a ClassMethodDecorator type defined in the original decorator proposal.

type ClassMethodDecorator = (value: Function, context: {
  kind: "method";
  name: string | symbol;
  access: { get(): unknown };
  static: boolean;
  private: boolean;
  addInitializer(initializer: () => void): void;
}) => Function | void;

There are many decorator context types available. lib.decorator.d.ts defines the following decorators.

type ClassMemberDecoratorContext =
    | ClassMethodDecoratorContext
    | ClassGetterDecoratorContext
    | ClassSetterDecoratorContext
    | ClassFieldDecoratorContext
    | ClassAccessorDecoratorContext
    ;

/**
 * The decorator context types provided to any decorator.
 */
type DecoratorContext =
    | ClassDecoratorContext
    | ClassMemberDecoratorContext
    ;

You can read from the names exactly which part of a class they target.

Note that we haven’t written detailed types, yet. We resort to a lot of any, but mostly because the types can get very complex. If we want to add types for all parameters, we need to resort to a lot of generics.

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    return value.call(this, ...args);
  };
}

The generic type parameters are necessary to describe the method we are passing in. We want to catch the following types:

  • This is a generic type parameter for the this parameter type (see Recipe 2.7). We need to set this as decorators are run in the context of an object instance.

  • Then we have the method’s arguments as Args. As we learned in Recipe 2.4, a method or function’s arguments can be described as a tuple.

  • Last, but not least, the Return type parameter. The method needs to return a value of a certain type, and we want to specify this.

With all three of them, we are able to describe the input method as well as the output method in the most generic way, for all classes. We can use generic constraints to make sure that our decorator only works in certain cases, but for log, we want to be able to log every method call.

Note

At the time of writing, ECMAScript decorators in TypeScript are fairly new. Note that types get better over time, so it might be that the type information you get is already much better.

We also want to log our class fields and their initial value before the constructor method is being called.

class Toggler {
  @logField #toggled = false;

  @log
  toggle() {
    this.#toggled = !this.#toggled;
  }
}

For that, we create another decorator called logField, which works on a ClassFieldDecoratorContext. The decorator proposal describes the decorator for class fields as follows.

type ClassFieldDecorator = (value: undefined, context: {
  kind: "field";
  name: string | symbol;
  access: { get(): unknown, set(value: unknown): void };
  static: boolean;
  private: boolean;
}) => (initialValue: unknown) => unknown | void;

Note that the value is undefined. The initial value is being passed to the replacement method.

type FieldDecoratorFn = (val: any) => any;

function logField<Val>(
  value: undefined,
  context: ClassFieldDecoratorContext
): FieldDecoratorFn {
  return function (initialValue: Val): Val {
    console.log(`Initializing ${context.name.toString()} to ${initialValue}`);
    return initialValue;
  };
}

There’s one thing that feels off. Why would we need different decorators for different kinds of members? Shouldn’t our log decorator be capable of handling it all? Our decorator is called in a specific decorator context, and we can identify the right context via the kind property (a pattern we saw in Recipe 3.2). So nothing easier than writing a log function that does different decorator calls depending on the context, right?

Well, yes and no. Of course, having a wrapper function that branches correctly is the way to go, but the type definitions — as we’ve seen — are pretty complex. Finding this one function signature that can handle them all is close to impossible without defaulting to any everywhere. And remember: We need the right function signature typings, otherwise the decorators won’t work with class members.

Multiple different function signatures just scream function overloads to us. So instead of finding one function signature for all possible decorators, we create overloads for field decorators, method decorators, etc. Here, we can type them just as we would type the single decorators. The function signature for the implementation takes any for value and brings all required decorator context types in a union, so we can do proper discrimination checks afterward.

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return;
function log<Val>(
  value: Val,
  context: ClassFieldDecoratorContext
): FieldDecoratorFn;
function log(
  value: any,
  context: ClassMethodDecoratorContext | ClassFieldDecoratorContext
) {
  if (context.kind === "method") {
    return logMethod(value, context);
  } else {
    return logField(value, context);
  }
}

Instead of fumbling all the actual code into the if-branches, we’d rather call the original methods. If you don’t want to have your logMethod or logField functions exposed, then you can put them in a module and only export log.

Tip

There are a lot of different decorator types out there and they all have various fields that slightly differ from each other. The type definitions in lib.decorators.d.ts are excellent, but if you need a bit more information, check out the original decorator proposal at TC39. Not only does it include extensive information on all types of decorators, it also contains additional TypeScript typings that complete the picture.

There is one last thing we want to do: We want to adapt logMethod to both log before and after the call. For normal methods, it’s as easy as temporarily storing the return value.

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
) {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    const val = value.call(this, ...args);
    console.log(`called ${context.name.toString()}: ${val}`);
    return val;
  };
}

But for asynchronous methods, things get a little more interesting. Calling an asynchronous method yields a Promise. The Promise itself might already have been executed, or the execution is deferred to later. This means if we stick with the implementation from before, the called log message might appear before the method actually yields a value.

As a workaround, we need to chain the log message as the next step after the Promise yields a result. To do so, we need to check if the method is actually a Promise. JavaScript promises are interesting because all they need to be awaited is having a then method. This is something we can check in a helper method.

function isPromise(val: any): val is Promise<unknown> {
  return (
    typeof val === "object" &&
    val &&
    "then" in val &&
    typeof val.then === "function"
  );
}

And with that, we decide whether to log directly or deferred based on the fact if we have a Promise or not.

function logMethod<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    const val = value.call(this, ...args);
    if (isPromise(val)) {
      val.then((p: unknown) => {
        console.log(`called ${context.name.toString()}: ${p}`);
        return p;
      });
    } else {
      console.log(`called ${context.name.toString()}: ${val}`);
    }

    return val;
  };
}

Decorators can get very complex but are ultimately a useful tool to make classes in JavaScript and TypeScript more expressive.

1 C# and TypeScript are made by Microsoft, and Anders Hejlsberg has been heavily involved in both programming languages

2 Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides: Design Patterns: Elements of Reusable Object-Oriented Software, 1994, Addison-Wesley