Update 2020-04-13: Major rewrite of this blog post.
In this blog post about TypeScript, 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);
The following diagram 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 the class) and Object.prototype
(which holds the prototype methods of class Object
).In this blog post, 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:
.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: any): JsonInstance;
}
// Converting instances to JSON
interface JsonInstance {
toJson(): any;
}
We use these interfaces in the following code:
class Person implements JsonInstance {
static fromJson(json: any): Person {
if (typeof json !== 'string') {
throw new TypeError(json);
}
return new Person(json);
}
name: string;
constructor(name: string) {
this.name = name;
}
toJson(): any {
return this.name;
}
}
This is how we can check right away if class Person
(as an object) implements the interface JsonStatic
:
// Assign the class to a type-annotated variable
const personImplementsJsonStatic: JsonStatic = Person;
The following way of making this check may seem like a good idea:
const Person: JsonStatic = class implements JsonInstance {
// ···
};
However, this approach doesn’t really work:
new
-call Person
because JsonStatic
does not have a construct signature.Person
has static properties beyond .fromJson()
, TypeScript won’t let us access them.Object
and for its instances It is instructive to take a look at TypeScript’s built-in types:
On one hand, interface ObjectConstructor
is for class Object
itself:
/**
* Provides functionality common to all JavaScript objects.
*/
declare var Object: ObjectConstructor;
interface ObjectConstructor {
new(value?: any): Object;
(): any;
(value: any): any;
/** A reference to the prototype for a class of objects. */
readonly prototype: Object;
/**
* Returns the prototype of an object.
* @param o The object that references the prototype.
*/
getPrototypeOf(o: any): any;
}
On the other hand, interface Object
is for instances of Object
:
interface Object {
/** The initial value of Object.prototype.constructor is the standard built-in Object constructor. */
constructor: Function;
/** Returns a string representation of an object. */
toString(): string;
}
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
:
function func(instanceOfColor: Color) {}
func(new Color('green'));
Here is the 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 make the two groups of objects incompatible by adding private properties:
class Color {
name: string;
private branded = true;
constructor(name: string) {
this.name = name;
}
}
class Person {
name: string;
private branded = true;
constructor(name: string) {
this.name = name;
}
}
const person: Person = new Person('Jane');
// @ts-ignore: Type 'Person' is not assignable to type 'Color'.
// Types have separate declarations of a private property 'branded'. (2322)
const color: Color = person;
The private properties switch off structural typing in this case.