TypeScript uses structural typing, this means when comparing types, TypeScript only takes into account structure of type. This means if the type is shaped like a duck, it’s a duck. If a goose has all the same attributes as a duck, then it also is a duck.
This can have drawbacks, particularly for anything involving user input, as they make it impossible to statically prove that all input is validated before it is used. For example:
Nominal typing means that each type is unique and even if types have the same data you cannot assign across types. For example, in C, two struct types with different names in the same translation unit are never considered compatible, even if they have identical field declarations.
Nominal typing is a subset of structural typing, in that two types cannot be equivalent in a nominal typing system unless they are structurally equivalent (else, the typing system would be unsound). Likewise for the subtype relationship.
The cost is a reduced flexibility, as, for example, nominal typing does not allow new super-types to be created without modification of the existing subtypes.
We can get most of the value from a nominal type system with a little bit of extra code in TypeScript. The idea is declaring private properties with special values that are unlikely to be accidentally duplicated elsewhere, despite still being “visible” to the outside, can be sufficient to signal to the type checker, “They are actually different”.
By using the brands (naming convention) we ensure that the type checker actually thinks you have something of the right type. brand properties are never actually given values. At runtime they have zero cost.
interface Duck {
__duckBrand: any
name: string
}
interface Dog {
__dogBrand: any
name: string
}
function play(duck: Duck, dog: Dog) {
console.log(`Play with ${duck.name} and ${dog.name}`)
}
const duck = new Duck('Jess')
const dog = new Dog('Loki')
play(duck, dog) // okay
play(dog, duck) // error
Brands give us a small amount of nominal typing. Consider Duck, Without the brand, Duck is actually no different (structurally) than Dog. Because of this you can pass any Duck to a function that takes a Dog without any error.
We’re going to use an intersectional type, with a unique constraint in the form of a brand property which makes it impossible to assign a normal string to a ValidatedInputString.
type ValidatedInputString = string & {
__brand: 'User Input Post Validation'
}
We will use a function to transform a string to a ValidatedInputString - but the point worth noting is that we’re just telling TypeScript that it’s true.
const validateUserInput = (input: string) => {
const simpleValidatedInput = input.replace(/\</g, '≤')
return simpleValidatedInput as ValidatedInputString
}
Now we can create functions which will only accept our new nominal type, and not the general string type.
const printName = (name: ValidatedInputString) => {
console.log(name)
}
For example, here’s some unsafe input from a user, going through the validator and then being allowed to be printed. On the other hand, passing the un-validated string to printName will raise a compiler error.
const input = "alert('bobby tables')"
const validatedInput = validateUserInput(input)
printName(validatedInput) // okay
printName(input) // error
This is the same concept as branding, but because the brand is private, it does not show up as a property on the object.
class Duck {
private __nominal: void
constructor(public name: string) {}
}
class Dog {
private __nominal: void
constructor(public name: string) {}
}
function play(duck: Duck, dog: Dog) {
console.log(`Play with ${duck.name} and ${dog.name}`)
}
const duck = new Duck('Jess')
const dog = new Dog('Loki')
play(duck, dog) // okay
// Argument of type 'Dog' is not assignable to parameter
// of type 'Duck'. Types have separate declarations of
// a private property '__nominal'.
play(dog, duck) // error