Essentials of TypeScript Classes

Essentials of TypeScript Classes

refine repo


Introduction

TypeScript supports all the features of JavaScript Class syntax introduced in ES2015. Basically, type annotations are applied to all members, namely: fields, constructors, methods and accessors -- and where applicable, parameters as well. TypeScript also bakes in a special syntax to class constructors called parameter properties which allows us to declare a class field from the constructor function's parameters.

A TypeScript class definition creates a type from itself and it is used to validate conformity of an instance. TypeScript allows generic classes with type parameters passed to the outer class definition. Usually, generic class type parameters are accepted as constructor parameters, but they can also be passed to fields, methods and accessors as well. A single TS class can implement multiple other interfaces, something that is done with the implements keyword.

Besides type annotations, TypeScript adds member visibility across the prototype chain with three access modifiers: public, protected and private -- a feature distinct from how ES2022 implements member privacy with #.

JavaScript this keyword leads to some unpredictability in different call site contexts. TypeScript is geared to mitigate during development some of the call site uncertainties by allocating a possible this parameter to a method's first argument.

Overview

In this post, we focus on the essentials of class based programming in TypeScript using a simple User class. We begin with how type annotations are applied to different class members and their parameters.

We first consider typing class fields and delve into details of their initialization options, particularly investigating definite initialization with the bang ! operator and strict initialization with the --strictPropertyInitialization flag.

We then familiarize with how member visibility is implemented in TypeScript. Member visibility in TypeScript classes is largely related to effective usage of prototypal heritage in JavaScript. However, in this post, we don't cover inheritance in TypeScript classes: for brevity, we only consider privacy of fields for a simple uninherited class and its instances. We also touch base on static fields which acts the same as that in JavaScript.

We elaborate on what readonly fields are and how they are limited to be initialized at the top or re/assigned from a constructor function. We extensively cover typing a constructor function with examples from our uninherited User class and relate that constructor parameters are typed similar to any TS function. We end up learning how parameter properties work inside a constructor. Moving forward, we also work our way through easy-to-pick examples of typing methods and accessors, along with their parameters.

In the later half of this post, we zoom in on the way TypeScript mitigates errors related to the this object. We expound on how arrow functions and the special TS this parameter in non-arrow functions can be used for correctly setting a class method's this object and also learn about some of their caveats.

We also explore generic classes with passed in type parameters and see examples of how TypeScript facilitates class conformity to multiple interfaces with the implements keyword.

Towards the end, we briefly discuss the structural type system that TypeScript bases itself on. We observe with an example how instances of different but identically typed and subtype classes conform to a given class (or rather the type from it) and how a supertype cannot not conform to a subtype because of missing properties.

Before we begin with type annotation examples, in the next section, let's first go through how to set up the environment for using TypeScript.

Typing Class Members in TypeScript

A TypeScript class commonly has type annotations for its members and where applicable, their parameters. In the following sections, one by one, we cover the details of typing TypeScript class fields, constructor functions, methods, accessors and their parameters.

Let's start with typing fields.

Typing Fields in TypeScript

Below is an unsophisticated example with a few fields for a User class:

class User {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
}

As you can notice, typing a class field in TypeScript is done like typing a variable. For example, as the usual story goes, the type of username is being inferred from its initializer type. With the rest of the properties, we are being explicit about the types for firstName, lastName and age!.

TypeScript Classes - Field Initialization

TypeScript class syntax adds some particular options to field initializations. A field may be initialized at declaration, or remain uninitialized, or uninitialized but aimed to be initialized definitely at some point during runtime.

For example, in the User class, username field is assigned a random string and the name fields are uninitialized. Notice the age! field with a bang!

TypeScript Classes - Definite Field Assignments

age above is uninitialized but it is accompanied by a bang (!) operator which is called the definite assignment assertion operator. It is used to indicate that leaving the field uninitialized is good enough to avoid TypeScript strict property initialization (see next section) error but it is expected to be definitely assigned a value with the specified type at some point.

It is common to use definite assignments when fields are assigned to an instance by APIs from some external libraries:

const joe = new User();

// Set joe's age externally
joe.age = getUserInfoFromStatsBureau("someId")?.data?.age;

TypeScript Classes - Strict Field Initialization

The --strictPropertyInitialization flag in TypeScript controls how strict field/property initialization should be. We can set the strictness of property initialization from the tsconfig.json file using the following entry to compilerOptions:

// Inside tsconfig.json

{
  "compilerOptions": {
    "strictPropertyInitialization": true
  }
}

In TypeScript Playground, you can activate strict property initialization first by visiting the TS Config dropdown and then selecting strictPropertyInitialization from the Type Checking section.

Setting "strictPropertyInitialization": true necessitates all fields to either have an initializer, or they should be set in the constructor function, or they should be definitely assigned at a later point. Otherwise, TypeScript throws a 2564 error:

// With --strictPropertyInitialization

class User {
  // Initialized, so no error
  username = "randomString";

  // Assigned in constructor, so no error
  private firstName: string;

  // Not assigned in constructor
  private lastName: string; // Property 'lastName' has no initializer and is not definitely assigned in the constructor.(2564)

  // Removing bang (!) also throws 2564 error
  protected age: number; // Property 'age' has no initializer and is not definitely assigned in the constructor.(2564)

  constructor(firstName: string) {
    this.firstName = firstName;
  }
}

TypeScript Class Member / Field Visibility

TypeScript offers public, protected and private visibility options for its members. These privacy options are different from how JavaScript implements member privacy in ES2022.

Visibility in TypeScript classes is a general feature applicable to all members. We are covering it for fields, but the same principles apply to methods as well.

Fields that are not designated any privacy are by default public. We can access or set public properties from an instance:

class User {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
}

const joe = new User();
joe.username = "jos3ph";
joe.firstName = "Joseph";
joe.lastName = "Hiyden";
joe.age = 63;

console.log(joe.username); // "jos3ph"
console.log(`${joe.firstName} ${joe.lastName}`); // "Joseph Hiyden"
console.log(joe.age); // 63

We have to explicitly state when a field or any member should be private or protected. private visibility restricts member access and assignment to within the class. protected limits the member to be accessed and set from its subclasses as well. This means that we can't access or set private or protected fields from an instance. Attempting to do so, as shown in the series of log statements below, throws errors:

class User {
  username = "randomString";
  private firstName: string;
  private lastName: string;
  protected age!: number;
}

const joe = new User();
joe.username = "jos3ph";
joe.firstName = "Joseph"; // Property 'firstName' is private and only accessible within class 'User'.(2341)
joe.lastName = "Hidden"; // Property 'firstName' is private and only accessible within class 'User'.(2341)
joe.age = 63; // Property 'age' is protected and only accessible within class 'User' and its subclasses.(2445)

console.log(joe.username);
console.log(`${joe.firstName} ${joe.lastName}`); // 2341 Errors
console.log(joe.age); // 2445 Error

TypeScript Classes - Static Members / Fields

Just as in JavaScript, we set class members on TypeScript classes with the static keyword. Let's introduce a static field userType to our User class:

class User {
  public static userType: string = "Guest";

  username = "randomString";
  protected age!: number;
}

console.log(User.userType); // "Guest"

As it happens in JavaScript, static fields in TypeScript represent class properties. One thing to note is that while declaring static fields, we have to place privacy modifiers (public here, which we technically don't need, but just to make a point) before the static keyword. Otherwise, TypeScript feels uncomfortable:

'public' modifier must precede 'static' modifier.(1029)

TypeScript Classes - readonly Fields

TypeScript allows fields to be readonly. As it implies, readonly fields tempt not be assigned from an instance, even with a setter. They are legal to be initialized at the top declaration and also assigned inside the constructor:

class User {
  static userType: string = "Guest";

  readonly _username: string = "randomString"; // No error at initialization
  protected age!: number;

  get username() {
    return this._username;
  }

  set username(username: string) {
    // Error while re/assignment from setter
    this._username = username; // Cannot assign to '_username' because it is a read-only property.(2540)
  }

  constructor(username: string) {
    this._username = username; // No error while assigned from constructor
  }
}

const dona = new User("trump");

// Error while being assigned from instance property, but gets assigned at compilation
console.log((dona._username = "trump_trippin")); // Cannot assign to '_username' because it is a read-only property.(2540)

TypeScript Classes - Constructor Functions

As you already have noticed above, just like in regular TS functions that take parameters, class constructor parameters also get annotated with their types. Below is a more common example:

class User {
  username = "randomString";
  private firstName: string;
  private lastName: string;
  protected age!: number;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

It is important to note that a constructor function in a TypeScript class does not take types as parameters. In other words, there is nothing like this:

class User {
  // Constructor fn cannot accept type param
  constructor<AbsurdTypeParam>() {}
}

Instead, the class declaration itself takes type parameters. Type parameters passed to a class are useful for defining generic class types, since a class ends up creating its own type. We'll explore generic classes in a later section.

TS Classes - Constructor Return Type

It should be also noted that we do not need to type the return value of a TypeScript class constructor. Because, it always returns the instance's type, which is the type created from the class.

class User {
  username = "randomString";
  private firstName: string;
  private lastName: string;
  protected age!: number;

  // Constructor's return type is the type of the class' instance
  constructor(firstName: string, lastName: string) {
    // constructor User(firstName: string, lastName: string): User
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

// joe is of type User
const joe = new User("Joe", "Hiyden"); // joe: User

TypeScript Class Creates a Type

It should be pretty obvious that a TypeScript class creates a type from itself:

// joe is of type User
const joe = new User("Joe", "Hiyden"); // joe: User

Typescript Classes - Parameter Properties

In TypeScript, we can turn a constructor parameter into a class property using parameter properties. The way to implement parameter properties is by designating field visibility modifiers (public, private, protected) and/or accessor modifiers (readonly) to respective constructor parameters, instead of declaring field definitions that we usually perform at the top:

class User {
  username = "randomString";
  protected age!: number;

  // Use field modifiers to declare parameter properties in constructor
  constructor(private firstName: string, private lastName: string) {
    // No assignments inside constructor body needed
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

const joe = new User("Joe", "Hiyden");
joe.firstName; // Property 'firstName' is private and only accessible within class 'User'.(2341)
console.log(joe.fullName()); // "Joe Hiyden"

Above, we have a reworked User class where we no longer need to declare firstName and lastName as fields at the top. Notice closely that we also don't need to carry out respective field assignments inside the constructor body. This way, TypeScript neatly keeps our code compact.

TypeScript Classes - Typing Methods

Applying type annotations to class methods is easy and follow the same principles as other functions. We already have the example of fullName() method above that has an inferred return type of string. In the below code, greetUserWith() is another method that has an explicit return type of string. It is annotated a string parameter as well:

class User {
  username = "randomString";
  protected age!: number;

  constructor(private firstName: string, private lastName: string) {}

  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  greetUserWith(greeting: string): string {
    return `${greeting}, ${this.fullName()}`;
  }
}

const joe = new User("Joe", "Hiyden");
console.log(joe.fullName()); // "Joe Hiyden"
console.log(joe.greetUserWith("Hello")); // "Hello, Joe Hiyden"

TypeScript Classes - Typing Accessors

In a similar vein, we can annotate types for accessor function parameters. Let's see how to do that for our protected _age field:

class User {
  username = "randomString";
  protected _age!: number;

  constructor(private firstName: string, private lastName: string) {}

  get age(): number {
    return this._age;
  }

  set age(age: number) {
    this._age = age;
  }
}

const joe = new User("Joe", "Hiyden");
joe.age = 20;
console.log(joe.age); // 20

It is worth noting that although we can annotate a type for the return value of get accessors, TypeScript complains if we assign a type for the return value of setters. Annotating a return type for setters is not allowed, so the following is invalid:

set age(age: number): number { // A 'set' accessor cannot have a return type annotation.(1095)
    this._age = age;
};

There are a couple of quirks related to accessors typing in TypeScript. Let's consider them now.

TS Classes - Setter Parameter Type Inferred from Existing Getter Param Type

For example, the above age() setter can have its parameter type omitted. That's because when a getter exists, the setter's type parameter is inferred from the return type of getter:

class User {
  username = "randomString";
  protected _age!: number;

  constructor(private firstName: string, private lastName: string) {}

  // Existing getter with `number` return type
  get age(): number {
    return this._age;
  }

  // Type of setter parameter inferred from return type of existing getter
  set age(age) {
    // (parameter) age: number
    this._age = age;
  }
}

const joe = new User("Joe", "Hidin");
joe.age = 20;
console.log(joe.age); // 20

TS Classes - Field With Only Getter is Set to readonly

When we have only a getter method, and no corresponding setter, the field is automatically set to readonly:

class User {
  username = "randomString";
  protected _age!: number;

  constructor(private firstName: string, private lastName: string) {}

  get age(): number {
    return this._age;
  }
}

const joe = new User("Just", "Kiddin");

// Assignment gives error with read-only message
joe.age = 20; // Cannot assign to 'age' because it is a read-only property.(2540)

this Object in TypeScript Classes

In JavaScript, the this object on which a method is called depends on the call site of the method. At runtime, the this object can be one of the root causes of unpredictable outcomes of a method call. In this section, we consider how TypeScript has a couple of options for controlling the this object predictably in order to produce more stable outcomes.

TypeScript Classes - Arrow Functions for Permanently Attaching this Object

As with JavaScript, when we want to permanently attach a class instance to a method, we can use the arrow syntax to define our method. For example, a redefined fullName() method with arrow syntax:

class User {
  username = "randomString";
  protected age!: number;

  constructor(private firstName: string, private lastName: string) {}

  // highlight-next-line
  fullName = () => `${this.firstName} ${this.lastName}`;

  greetUserWith(greeting: string) {
    return `${greeting}, ${this.fullName()}`;
  }
}

const joe = new User("Joe's", "Kiddin");
console.log(joe.fullName()); // "Joe's Kiddin"

// Doesn't lose `this` context, because it is permanently bound to instance
const jfn = joe.fullName;
console.log(jfn()); // "Joe's Kiddin"

As it happens in JavaScript, the arrow syntax permanently binds the fullName method to the instance of class User, joe here. So, regardless of whether we invoke it directly on joe or extract it and call it later on, the this object remains joe.

One of the caveats of using context binding with arrow syntax is that in a derived class of User, we can't access super.fullName() as arrow functions don't have a prototype property.

TypeScript Classes - Context Binding with this Parameter

Another way TypeScript helps handle method context binding is that it spares the this object for the first parameter to every method or accessor. When we want to bind an instance of the class to the method, we can specify the instance as the this parameter and type it as the class itself. Like this:

class User {
  username = "randomString";
  protected age!: number;

  constructor(private firstName: string, private lastName: string) {}

  // highlight-next-line
  fullName(this: User) {
    return `${this.firstName} ${this.lastName}`;
  }

  greetUserWith(greeting: string) {
    return `${greeting}, ${this.fullName()}`;
  }
}

const joe = new User("Joe's", "Hidin");
console.log(joe.fullName()); // "Joe's Hidin"

// Error when taken out of context
const jfn = joe.fullName;
console.log(jfn()); // The 'this' context of type 'void' is not assignable to method's 'this' of type 'User'.(2684)

Context binding with the this parameter is specifically useful when we are sure to use the method on an instance of the User class, and without taking it out of context. An added advantage is that we can also call it from a derived class using super.

The drawback, as we can see above, is that the method loses the instance as its this when it is extracted out of context.

TypeScript Generic Classes

As it does with other generic types, TypeScript allows us to declare generic classes by passing in type parameters at class declaration. The passed in type can then be used to annotate types for any member inside the class.

Here's a simple example of generic class with modifications to an earlier example:

class User<T> {
  readonly userType: T;

  username = "randomString";
  protected age!: number;

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

type UserTypes = "Guest" | "Authenticated" | "Admin";

const joe = new User<string>("Guest");
const dae = new User<UserTypes>("Authenticated");
const dan = new User<UserTypes>("Unknown"); // Argument of type '"Unknown"' is not assignable to parameter of type 'UserTypes'.(2345)

console.log(joe.userType); // "Guest"
console.log(dae.userType); // "Authenticated"

It is, however, not legal to pass class type parameters to static members:

class User<T> {
  static readonly userType: T; // Static members cannot reference class type parameters.(2302)

  username = "randomString";
  protected age!: number;
}

TypeScript Classes - Multiple Interfaces with implements

It is possible for a TypeScript class to implement more than one interface. We use the implements clause for this. Any interface that the class satisfies can be passed to implements. For example, the following interfaces are all satisfied by the User class:

interface Identifiable {
  fullName(): string;
}

interface Greetable {
  greetUserWith(greeting: string): string;
}

interface Updatable {
  updateUsername(username: string): void;
}

class User<T> implements Identifiable, Greetable, Updatable {
  readonly userType: T;

  username = "randomString";
  protected age!: number;

  constructor(userType: T, private firstName: string, private lastName: string) {
    this.userType = userType;
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  greetUserWith(greeting: string) {
    return `${greeting}, ${this.fullName()}`;
  }

  updateUsername(username: string) {
    this.username = username;
  }
}

const joe = new User<string>("Guest", "Joe", "Hidden");
console.log(joe.fullName()); // "Joe Hidden"
console.log(joe.greetUserWith("Hello")); // "Hello, Joe Hidden"

TypeScript throws a 2420 error when a given interface property is not satisfied by the class. For example, for a Registerable interface, the register method is not implemented by User, so it does not satisfy the Registerable interface:

interface Identifiable {
  fullName(): string;
}

interface Greetable {
  greetUserWith(greeting: string): string;
}

interface Updatable {
  updateUsername(username: string): void;
}

interface Registerable {
  register(userId: string): void;
}

// Complains with 2420 error because `register()` method is missing in User
class User<T> implements Identifiable, Greetable, Updatable, Registerable {
  //
  readonly userType: T;

  username = "randomString";
  protected age!: number;

  constructor(userType: T, private firstName: string, private lastName: string) {
    this.userType = userType;
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  greetUserWith(greeting: string) {
    return `${greeting}, ${this.fullName()}`;
  }

  updateUsername(username: string) {
    this.username = username;
  }
}

TypeScript Classes - Relationship Between Class Types

TypeScript has a structural type system. And in structural type systems, the shape of the class and their instances are enough to compare them.

TypeScript Classes - Classes with Identical Shapes are Type Compliant

If the shapes of two classes are identical, their types are compliant:

// With --strictPropertyInitialization set to false

class User {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
}

class Admin {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
}

// No complains when we type instance of Admin with User and vice versa, because User and Admin are structurally identical
const joe: User = new Admin(); // joe: User
const dona: Admin = new User(); // joe: Admin

Here, we are able to type joe: an instance of Admin with User, and dona: an instance of User with Admin because the shapes of the two classes are the same.

TypeScript Classes - Subtyped Classes are Type Compliant

Similarly, subtyped classes that have partial but the same members with a supertype is compliant to the supertype:

class User {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
}

class Admin {
  username = "randomString";
  firstName: string;
  lastName: string;
  age!: number;
  role: string = "Admin";
}

// No complains typing instance of Admin with User, because User is a subtype of Admin
const joe: User = new Admin(); // joe: User

// This time around, we can't type instance of User with Admin, because missing property in supertype
const dae: Admin = new User(); // Property 'role' is missing in type 'User' but required in type 'Admin'.(2741)

In this example, joe, is still compliant to User because the Admin has all the members of User and an additional one. The opposite (dae: Admin) is not true though, because User has the missing member role that is present in Admin.

Summary

In this post, we have traversed a long way in our exploration of classes in TypeScript. We have covered the essentials of type annotation in TS classes. We began with how to type class fields, their initialization options and visibility modifiers. We touched on static fields, and with an example covered the concept of readonly fields that TypeScript implements. We have went through in depth how class constructor, method and accessor parameters, and their return values are annotated. We saw how readonly properties can be assigned from a constructor function, and how to implement parameter properties.

We also expounded on how arrow functions are used to bind a method permanently to an instance and discovered how the this parameter in TypeScript methods allows us to bind an instance more selectively to its methods.

Near the end, we learned about how a class should implement multiple interfaces with the implement clause. We also explored how subtypes from classes are compliant to those from supertyped classes and and not the other way around because of TypeScript's structural typing system.