Nominal typing in TypeScript or how to protect your interface from alien identifiers


Recently, studying the reasons for the incorrect operation of my home project, I once again noticed a mistake that often repeats due to fatigue. The essence of the error is that, having several identifiers in one code block, when I call a function, I pass the identifier of an object of another type. In this article I will talk about how to solve this problem using TypeScript.


Bit of theory


TypeScript is based on structural typing, which fits well with the duck ideology of JavaScript. A sufficient number of articles have been written about this. I will not repeat them, I will only outline the main difference from nominative typing, which is more common in other languages. Let's take a look at a small example.


class Car {
    id: number;
    numberOfWheels: number;
    move (x: number, y: number) {
        // некая реализация
    }
}
class Boat {
    id: number;
    move (x: number, y: number) {
        // некая реализация
    }
}
let car: Car = new Boat(); // здесь TypeScript выдаст ошибку
let boat: Boat = new Car(); // а на этой строчке все в порядке

Why does TypeScript behave this way? This is just a manifestation of structural typing. Unlike nominative, which monitors type names, structural typing makes a decision on the compatibility of types based on their contents. The Car class contains all the properties and methods of the Boat class, so Car can be used as a Boat. The converse is not true because Boat does not have the numberOfWheels property.


Typing identifiers


First of all, we will set types for identifiers


type CarId: number;
type BoatId: number;

and rewrite the classes using these types.


class Car {
    id: CarId;
    numberOfWheels: number;
    move (x: number, y: number) {
        // некая реализация
    }
}
class Boat {
    id: BoatId;
    move (x: number, y: number) {
        // некая реализация
    }
}

You will notice that the situation has not changed much, because we still do not have control over where we got the identifier from, and you will be right. But this example already gives some advantages.


  1. During program development, the type of identifier may suddenly change. So, for example, a certain car number, unique to the project, can be replaced by a string VIN number. Without specifying the type of identifier, you will have to replace number with string in all places where it occurs. With the task of type, the change will need to be done only in one place where the type itself is determined.


  2. When calling functions, we get hints from our code editor, what type identifiers should be. Suppose we have the following functions declared:


    function getCarById(id: CarId): Car {
    // ...
    }
    function getBoatById(id: BoatId): Boat {
    // ...
    }

    Then we will get a hint from the editor that we must transmit not just a number, but CarId or BoatId.



Emulate the strictest typing


There is no nominal typing in TypeScript, but we can emulate its behavior, making any type unique. To do this, add a unique property to the type. This trick is referred to in the English-language articles under the term Branding, and here is what it looks like:


type BoatId = number & { _type: 'BoatId'};
type CarId = number & { _type: 'CarId'};

Having pointed out that our types must be both a number and an object with a property with a unique value, we made our types incompatible in understanding structural typing. Let's see how it works.


let carId: CarId;
let boatId: BoatId;
let car: Car;
let boat: Boat;
car = getCarById(carId); // OK
car = getCarById(boatId); // ERROR
boat = getBoatById(boatId); // OK
boat = getBoatById(carId); // ERROR
carId = 1; // ERROR
boatId = 2; // ERROR
car = getCarById(3); // ERROR
boat = getBoatById(4); // ERROR

Everything looks good except for the last four lines. To create identifiers, you need a helper function:


function makeCarIdFromVin(id: number): CarId {
    return vin as any;
}

The disadvantage of this method is that this function will remain in runtime.


Making strong typing a little less strict


In the last example, I had to use an additional function to create the identifier. You can get rid of it using the Flavor interface definition:


interface Flavoring {
  _type?: FlavorT;
}
export type Flavor = T & Flavoring;

Now you can set types for identifiers as follows:


type CarId = Flavor
type BoatId = Flavor

Since the _type property is optional, you can use an implicit conversion:


let boatId: BoatId = 5; // OK
let carId: CarId = 3; // OK

And we still cannot mix up the identifiers:


let carId: CarId = boatId; // ERROR

Which option to choose


Both options have a right to exist. Branding has the advantage of protecting a variable from direct assignment. This is useful if the variable stores the string in some format, such as an absolute file path, date or IP address. The helper function that deals with type conversion in this case can also check and process input data. In other cases, it is more convenient to use Flavor.


Sources


  1. Starting point stackoverflow.com
  2. Free interpretation of the article

Also popular now: