*TikTok music playing* You can do anything you want when you're... a programmer! But for real - programming has become a way of reflecting real-world, often providing a helping hand in various processes by using code. We can create all sorts of things.
Those things can have different shapes and meanings, serve different purposes and do all sorts of different stuff. Like making a man become a starship. Let's learn how to achieve that using TypeScript and, on the ocassion, gain some knowledge about structural and nominal typing.
TypeScript can be a good example of our starship-theorem because it's here for you when you need it. Nowadays, it's basically everywhere - both in front-end and back-end worlds, but to be precise and effective you have to remember some important things and use them to provide a valid solution to the problem.
Our assumption is as follows:
"A man cannot become a starship."
Sounds very simple and straightforward, right? Well, it's actually not so simple and I'll prove it to you.
Shaping the world
Let's play with our assumption in TypeScript. Let's say we have a man and a starship - they all have the same properties: name and id:
interface Person {
name: string;
id: number;
}
interface Starship {
name: string;
id: number;
}
Let's prepare some typical real-world situation:
const person: Person = {
name: 'Han Solo',
id: 1,
}
const starship: Starship = person;
Surprisingly, the code above is absolutely correct and will compile without any errors, but if you think about it, our assumption is broken. Why is that?
TypeScript (as the name suggests...) is built over types. If something has the exact same properties, it's the same thing. It doesn't matter that it's technically a different interface.
The difference comes when one of the interfaces is more specific than the other. Let's discuss the scenario as below:
interface Person {
name: string;
id: number;
}
interface Starship {
name: string;
id: number;
serialNumber: string; // newly added field
}
This time, our real-world situation will behave a little differently:
const person: Person = {
name: 'Han Solo',
id: 1,
}
const starship: Starship = person;
// Compiler error:
// Property serialNumber is missing in type 'Person'
// but required in type 'Starship'.
Compiler error message pretty much sums that up, but why is that happening? By adding a new field serialNumber to the Starship interface it became a more specific type than the Person, which made it impossible assigning its value to the starship. Let's now change the rules and invert the real-world scenario:
const starship: Starship {
name: 'Millenium Falcon',
id: 1,
serialNumber: 'YT 492727ZED'
}
const person: Person = starship;
The above scenario will compile successfully, because the starship contains all fields required in person type (name, id), so it can in fact become one.
To sum that up, it's safe to say that:
Types are considered the same if they share the same structure (fields).
More specific type can be assigned to the less specific type (but has to come with all required fields of the less specific type).
Less specific type cannot be assigned to the more specific type (as it does not come with all required fields).
What does that mean for our assumption? It means that a man can actually become a starship, but only when they share the same fields.
In TypeScript, all of that is called structural typing, which is the default type checking mechanism. It works well in most cases, but there are some that require more precision. In those cases, nominal typing comes to the rescue.
Being more serious
In situations when type integrity becomes a key aspect of our code, we have to dive a little deeper into what TypeScript is able to provide.
Nominal typing is a star in this case. Being able to relate objects strictly based on their types, not their members, it stands in contrast with structural typing.
Currently TypeScript has no native support for nominal types (see history of the topic), but there are a few ways we can easily implement it ourselves.
Branding
The first technique is called branding. It requires adding a brand field with a string literal as a value. Let's get back to our previous real-world situation, but this time, let's 'brand' our interfaces:
interface BrandedPerson {
brand: 'person';
name: string;
id: number;
}
interface BrandedStarship {
brand: 'starship';
name: string;
id: number;
}
const person = {
name: 'Boba Fett',
id: 1,
} as BrandedPerson;
const starship: BrandedStarship = person;
// Compiler error:
// Types of property 'brand' are incompatible.
The above code is pretty much the same that we implemented before using structural typing, but this time the compiler stands strong against letting a man become the starship, as they are in fact a different types.
As you probably already noticed, this technique comes with the disadvantage of requiring the implementation of additional fake object properties.
Enum Intersected Types
Another way of implementing nominal typing is using enums. In TypeScript enums are unique, so any type intersected with an enum becomes unique as well. Let's use that knowledge in our scenario:
enum PersonType {}
type Person = PersonType & {
name: string;
id: number;
}
enum StarshipType {}
type Starship = StarshipType & {
name: string;
id: number;
}
const person = {
name: 'Boba Fett',
id: 1,
} as Person;
const starship: Starship = person;
// Compiler error:
// Type ... is not assignable to type Starship.
As before, this serves our purpose of not letting a man become a starship, but this time using a type intersection with unique enum.
This technique comes with the advantage of not adding any fake properties (as in branding), but also with the disadvantage of returning two type declarations for every type.
Private Class Members
Yet another way of handling nominal typing is the usage of private class members that denote the types. As previously, let's look at the example below:
class Person {
private person: void;
name: string;
id: number;
}
class Starship {
private starship: void;
name: string;
id: number;
}
const person = {
name: 'Boba Fett',
id: 1,
} as Person;
const starship: Starship = person;
// Compiler error:
// Property 'starship' is missing in type 'Person'
// but required in type 'Starship'.
Given the compiler error we've got, this method also serves our purpose.
The concept of this is actually the same as branding, but if you look closely it comes with the advantage of not showing up additional property (brand) on the object, as it is private. Private class members can also be encapsulated.
Private Class Members Encapsulation
Let's be like Agent 47 for a moment with elegant and effective technique and play with some encapsulation. Here are our fundamentals:
class Tagged<T> {
private _secret_tag: T
}
type Nominal<Type, Tag> = Type & Tagged<Tag>;
Having that prepared, let's get back to our previous scenario and code it using Nominal type:
type Person = Nominal<{
name: string;
id: number;
}, 'Person'>;
type Starship = Nominal<{
name: string;
id: number;
}, 'Starship'>;
const person = {
name: 'Boba Fett',
id: 1,
} as Person;
const starship: Starship = person;
// Compiler error:
// Type 'Person' is not assignable to type 'Starrship'.
Once again, the above implementation prevents a man from becoming a starship, which solves our issue.
The encapsulation gives us the power of hiding it in a utility file or utility library, which has a positive impact on our code quality. It also comes with the advantage of brand property not appearing on the object (like in previous techniques).
Motivation
Okay, we've come a long way since the start - we've learned about two different ways of type handling in TypeScript: structural and nominal typing and how to achieve them. But let's ask ourselves a question: is nominal typing really that important? It actually depends on the case.
Let's think about some system that requires encapsulation, e.g. encapsulation within modules - a scenario in which no code from the outside should interact with module code, except through explicit predefined channels.
In that case, nominal typing can be responsible for making sure that some predefined functions will not be called with simply any object that happens to have the same properties like the one that is required.
Nominal type makes a statement that the code using it is created specifically with this type in mind.
Let's go a little bit further with our encapsulation case and create a library that will play relaxing songs to keep us motivated in work for a certain amount of time. Instead of implementing it like that:
export function play<T>(time: number) {
this.playSongsByBand('Slayer', time);
}
we can use nominal typing:
export type Minutes = Nominal<number, 'Minutes'>;
export function play<T>(time: Minutes) {
this.playSongsByBand('Slayer', time);
}
As you can see, the above scenario benefits highly from using nominal typing. Instead of a function that takes an unspecified amount of time as a parameter, we end up with a self-explaining parameter in which you don't have to look to the documentation to understand its unit. And, as the type is nominal, you won't pass your age by mistake!
Conclusion
TypeScript mastery comes with understanding it. By knowing how it handles typing we can take our code to new heights.
Is nominal typing better than structural typing? No, the default typing mechanism will still be the one to go for most of the cases, but if you have a real need of being type sensitive, you know what you need to do.
We started with the starship-theorem. We did it not only because it gave me right to put here a cool transformers gif (or not only why) but also because it’s true. But to be true it must be as follows:
A man cannot become a starship. Unless you're using structural typing. Then in some cases it can...
A little bonus
Let's say we have a collection of different types that share the same property, e.g. name. We can implement a function that will take anything containing a name field and return its length.
function getNameLength(something: { name: string }) {
return something.name.length;
}
This way, you can pass any type (e.g. Person, Starship, Company, Band, etc.) containing name field to this function.
This can be useful, right?
Top comments (0)