In this chapter, we examine types related to classes and their instances.
Consider this class:
class Counter extends Object {
static createZero() {
return new Counter(0);
}
value: number;
constructor(value: number) {
super();
this.value = value;
}
increment() {
this.value++;
}
}
// Static method
const myCounter = Counter.createZero();
assert.ok(myCounter instanceof Counter);
assert.equal(myCounter.value, 0);
// Instance method
myCounter.increment();
assert.equal(myCounter.value, 1);
Figure 22.1: Objects created by class Counter
. Left-hand side: the class and its superclass Object
. Right-hand side: The instance myCounter
, the prototype properties of Counter
, and the prototype methods of the superclass Object
.
The diagram in figure 22.1 shows the runtime structure of class Counter
. There are two prototype chains of objects in this diagram:
Counter
. The prototype object of class Counter
is its superclass, Object
.
myCounter
. The chain starts with the instance myCounter
and continues with Counter.prototype
(which holds the prototype methods of class Counter
) and Object.prototype
(which holds the prototype methods of class Object
).
In this chapter, we’ll first explore instance objects and then classes as objects.
Interfaces specify services that objects provide. For example:
interface CountingService {
value: number;
increment(): void;
}
TypeScript’s interfaces work structurally: In order for an object to implement an interface, it only needs to have the right properties with the right types. We can see that in the following example:
const myCounter2: CountingService = new Counter(3);
Structural interfaces are convenient because we can create interfaces even for objects that already exist (i.e., we can introduce them after the fact).
If we know ahead of time that an object must implement a given interface, it often makes sense to check early if it does, in order to avoid surprises later. We can do that for instances of classes via implements
:
class Counter implements CountingService {
// ···
};
Comments:
We can implement
any object type (not just interfaces).
TypeScript does not distinguish between inherited properties (such as .increment
) and own properties (such as .value
).
Classes themselves are also objects (functions). Therefore, we can use interfaces to specify their properties. The main use case here is describing factories for objects. The next section gives an example.
The following two interfaces can be used for classes that support their instances being converted from and to JSON:
// Converting JSON to instances
interface JsonStatic {
fromJson(json: unknown): JsonInstance;
}
// Converting instances to JSON
interface JsonInstance {
toJson(): unknown;
}
We use these interfaces in the following code:
class Person implements JsonInstance {
static fromJson(json: unknown): Person {
if (typeof json !== 'string') {
throw new TypeError();
}
return new Person(json);
}
name: string;
constructor(name: string) {
this.name = name;
}
toJson(): unknown {
return this.name;
}
}
This is how we can check right away if class Person
(as an object) implements the interface JsonStatic
:
type _ = Assert<Assignable<JsonStatic, typeof Person>>;
If you don’t want to use a library (with the utility types Assert
and Assignable
) for this purpose, you can use the following pattern:
// Assign the class to a type-annotated variable
const personImplementsJsonStatic: JsonStatic = Person;
The downside of this pattern is that it produces extra JavaScript code.
It would be nice to avoid an external check – e.g., like this:
const Person = class implements JsonInstance {
static fromJson(json: unknown): Person { // (A)
// ···
}
// ···
} satisfies JsonStatic; // (B)
type Person = typeof Person.prototype; // (C)
In line B, we use the satisfies
operator, which enforces that the value Person
is assignable to JsonStatic
while preserving the type of that value. That is important because Person
should not be limited to what’s defined in JsonStatic
.
Alas, this alternative approach is even more verbose and doesn’t compile. One of the compiler errors is in line C:
Type alias 'Person' circularly references itself.
Why? Type Person
is mentioned in line A. Even if we rename the type Person
to TPerson
, that error doesn’t go away.
Object
and for its instancesIt is instructive to take a look at TypeScript’s built-in types:
On one hand, interface ObjectConstructor
is for the class pointed to by the global variable Object
:
declare var Object: ObjectConstructor;
interface ObjectConstructor {
/** Invocation via `new` */
new(value?: any): Object; // (A)
/** Invocation via function calls */
(value?: any): any;
readonly prototype: Object; // (B)
getPrototypeOf(o: any): any;
// ···
}
On the other hand, interface Object
(which is mentioned in line A and line B) is for instances of Object
:
interface Object {
constructor: Function;
toString(): string;
toLocaleString(): string;
valueOf(): Object;
hasOwnProperty(v: PropertyKey): boolean;
isPrototypeOf(v: Object): boolean;
propertyIsEnumerable(v: PropertyKey): boolean;
}
In other words – the name Object
is used twice, at two different language levels:
Consider the following class:
class Color {
name: string;
constructor(name: string) {
this.name = name;
}
}
This class definition creates two things.
First, a constructor function named Color
(that can be invoked via new
):
assert.equal(
typeof Color, 'function'
);
Second, an interface named Color
that matches instances of Color
:
const green: Color = new Color('green');
Here is proof that Color
really is an interface:
interface RgbColor extends Color {
rgbValue: [number, number, number];
}
There is one pitfall, though: Using Color
as a static type is not a very strict check:
class Color {
name: string;
constructor(name: string) {
this.name = name;
}
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
const person: Person = new Person('Jane');
const color: Color = person; // (A)
Why doesn’t TypeScript complain in line A? That’s due to structural typing: Instances of Person
and of Color
have the same structure and are therefore statically compatible.
We can turn Color
into a nominal type by adding a private field (or a private
property):
class Color {
name: string;
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
class Person {
name: string;
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
const robin: Person = new Person('Robin');
// @ts-expect-error: Type 'Person' is not assignable to type 'Color'.
// Property '#isBranded' in type 'Person' refers to a different member that
// cannot be accessed from within type 'Color'.
const color: Color = robin;
This way of switching off structural typing is called branding. Note that the private fields of Color
and Person
are incompatible even though they have the same name and the same type. That reflects how JavaScript works: We cannot access the private field of Color
from Person
and vice versa.
Let’s say we want to migrate the following code from the object type in line A to a class:
type Person = { // (A)
name: string,
};
function storePerson(person: Person): void {
// ...
}
storePerson({
name: 'Robin',
});
In our first attempt, invoking storePerson()
with an object literal still works:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
function storePerson(person: Person): void {
// ...
}
storePerson({
name: 'Robin',
});
Once we brand Person
, we get a compiler error:
class Person {
name: string;
#isBranded = true;
constructor(name: string) {
this.name = name;
}
}
function storePerson(person: Person): void {
// ...
}
// @ts-expect-error: Argument of type '{ name: string; }' is not assignable
// to parameter of type 'Person'. Property '#isBranded' is missing in type
// '{ name: string; }' but required in type 'Person'.
storePerson({
name: 'Robin',
});
This is how we fix the error:
storePerson(
new Person('Robin')
);