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 get
ter 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.
Author: Abdullah Numan
Top comments (0)