Here we look at two closely related concepts - the behaviour of Typescript’s type system, and how we can get the type of an object at runtime. We dive into structural typing, nominal typing, Typescript’s value and type spaces, classes, and enums.
Structural Typing
Typescript is a structurally typed language. This means that types are identified by their shape rather than name or alias. We can see this demonstrated below. Even though our sayName
function expects a Person
the Typescript compiler is quite happy to accept a Pet
.
type Person = {
name: string;
weight: number;
}
type Pet = {
name: string;
weight: number;
}
const basil: Person = { name: "Basil Exposition", weight: 65.4 };
const bigglesworth: Pet = { name: "Mr Bigglesworth", weight: 5.7 };
const sayName = (person: Person) => console.log(person.name);
sayName(basil);
// Basil Exposition
sayName(bigglesworth);
// Mr Bigglesworth
This makes more sense when we see how the Javascript code that is produced.
const basil = { name: "Basil Exposition", weight: 65.4 };
const bigglesworth = { name: "Mr Bigglesworth", weight: 5.7 };
const sayName = (person) => console.log(person.name);
sayName(basil);
sayName(bigglesworth);
sayName
’s only condition is that whatever is passed to it has a property called name
. This is duck typing, if it walks like a duck and it quacks like a duck, then it must be a duck, and follows the style you’d use to write typical Javascript or Python code.
Languages such as C, C#, and Java use nominal typing. The C# equivilent of the above Typescript code would not compile.
Structural Typing Not So Fast
The danger of structural typing is accidentally being able to interchangeably use two different type declarations which share the same structure. It’s quite common in languages like F# (a nominally typed language) to alias base types to gain additional safety at compile time.
type PersonId = int
type PetId = int
let deletePerson (id: PersonId) =
...
In a structurally typed language any int
could be passed to the deleteUser
function. This is definitely not what we want and goes against setting up the types in the first place. Further down we’ll see how we can safely do this in Typescript.
But I Want To Know the Type at Runtime
In Part I we saw that type checking is done at compile time. The Typescript compiler then erases the types and generates the Javascript code to run. So at runtime how can we discriminate between the type of the object that is being passed? That snippet above shows there is no Person
symbol anywhere in the generated code.
Coming from other programming languages we might expect we can do a check like so
console.log(basil instanceof Person)
// ReferenceError: Person is not defined
But we can’t, since there is no Person
symbol in the value space. Another idea would be to check the type
console.log(typeof basil)
// object
At runtime basil
is just an object, this follows along with the Javascript that was generated above. This illustrates the concept of the type space and value space. Our code has symbols, these are everything which we’ve assigned a name: types, classes, variables, constants, functions, etc.
When we write code we’re actually putting creating the symbol within either the type space or value space (there are a couple of cases where the symbol is created in both, we’ll get to that). Repeating the code from above..
type Person = {
name: string;
weight: number;
}
const basil: Person = { name: "Basil Exposition", weight: 65.4 };
This code creates the symbol Person
in the type space and the symbol basil
in the value space. A simple rule of thumb is anything that comes after a :
is in the type space and everything else is in the value space. Where things start getting difficult is that the behaviour of some Typescript operators depends on the space you call it. For example using typeof
in the type space lets us create a new type based on the structure of a constant object. You can see how this starts being useful in a structural type system.
const basil = { name: "Basil Exposition", weight: 65.4 };
type OtherPersonType = typeof basil;
/* type OtherPersonType = {
* name: string;
* weight: number;
*/ }
But we can also use typeof
in the context of the value space. When this code is executed at runtime it returns the Javascript type of the symbol.
console.log(typeof basil);
// object
It’s important to understand that the type space and value space are completely different. We can see how that at runtime, while in the value space, there is no way to get the name of the Typescript type. We’ll get onto that later, but here’s a short detour to look at a couple of constructs which exist in both type and value spaces.
Classes and Enums Confuse Things Further
We know that our type
and interface
symbols exist only in the type space, that is we cannot access them in the value space (ie. at runtime). Somewhat confusingly class
and enum
symbols are in both the type space and value space. For different reasons.
Typescript classes are just standard Javascript classes. Class symbols appear in both the type space (so we can do things like generics) as well as the value space, where they are the Javascript class symbol. So if we just declared our data types as a class
instead of a type
then we can differentiate them at runtime using the instanceof CPerson
line.
class CPerson {
name: string;
weight: number;
constructor(name: string, weight:number) {
this.name = name;
this.weight = weight;
}
}
const cbasil: CPerson = new CPerson("Basil Exposition", 65.4);
console.log(typeof cbasil);
// object
console.log(cbasil instanceof CPerson);
// true
Enums also exist in both the type and value spaces.
Unlike type
and interface
declarations, a Typescript enum
will generate Javascript that’s included at runtime.
enum Thoughts {
Baseball,
ColdShowers,
}
The Javascript object that’s generated.
"use strict";
var Thoughts;
(function (Thoughts) {
Thoughts[Thoughts["Baseball"] = 0] = "Baseball";
Thoughts[Thoughts["ColdShowers"] = 1] = "ColdShowers";
})(Thoughts || (Thoughts = {}));
Nominal Typing in Typescript
We’re still looking for a way to be able to identify the Typescript type of an interface
or type
at runtime. We’ve seen we can do that with classes and enums, however neither fit our use case.
Even though the Typescript type system uses structural typing we can introduce a sort of nominal type. We’ll do this in such a way that it’s possible to discriminate the type of the type
or interface
at runtime. We do this by just adding a string literal to the type definition.
type Person = {
_type: "Person";
name: string;
weight: number;
}
type Pet = {
_type: "Pet";
name: string;
weight: number;
}
const basil: Person = { name: "Basil Exposition", weight: 65.4, _type: "Person" };
const bigglesworth: Pet = { name: "Mr Bigglesworth", weight: 5.7, _type: "Pet" };
const sayName = (person: Person) => console.log(person.name);
sayName(basil);
// Basil Exposition
sayName(bigglesworth);
/* Compile time error
Argument of type 'Pet' is not assignable to parameter of type 'Person'.
Types of property '_type' are incompatible.
Type '"Pet"' is not assignable to type '"Person"'.(2345)
*/
That error makes sense. Typescript’s structural typing can see that Person
and Pet
differ by the string literal defined in _type
. Because this _type
property will be included on the Javascript objects we construct we can use it at runtime to discriminate between the types.
const sayTypeAndName = (thing: Person | Pet): void => {
switch (thing._type) {
case "Person":
console.log("Person: " + thing.name);
break;
case "Pet":
console.log("Pet: " + thing.name);
break;
}
}
sayTypeAndName(basil);
// Person: Basil Exposition
sayTypeAndName(bigglesworth);
// Pet: Mr Bigglesworth
Include a _type
Property Everywhere?
Adding a _type
property to all of our types (also called tagging or branding) seems like a bit of a hack. It is! Although it’s the best way we’ve got at the moment. The Typescript language itself uses the same approach.
The Typescript project does have nominal typing support on the roadmap. There’s an active thread started in 2014 which discusses different implementation options and the use cases for nominal types.