Structural vs Nominal typing
One of the most discussed features in typescript is that around "non-structural (nominal)" typing. This thread goes back nearly 8 years on the subject.
For the unawares reader, typescript's type system considers a type A to be equivalent to type B if B has at least the same members of A.
interface Person {
name: string;
age: number,
}
interface Alien {
name: string;
age: number,
planet: string;
}
declare const x: Alien
const y: Person = x // valid
declare const xx: Person
const y: Alien = xx // invalid
The informal label for this is duck-typing where there is a phrase "if it walks like a duck and quacks like a duck than it's probably a duck". This makes typescript's type system very flexible as developers can easily spin up their own interfaces where necessary and don't have to extend their dependency tree to find the exact type for an api.
A nominal type system has the property where all types are unique and distinct of each other, regardless of the nature of equivalence of their properties. Typescript does not support nominal types but as in the linked thread, there are discussions surrounding how they can be added. However, even in the current version of typescript, it is possible to implement a "simulation" of them while incurring some verbosity and minor inconveniences that I will touch on.
interface Person {
__brand: "Person";
name: string;
age: number,
}
interface Alien {
__brand: "Alien";
name: string;
age: number,
planet: string;
}
declare const x: Alien
const y: Person = x // invalid
// Type 'Alien' is not assignable to type 'Person'.
// Types of property '__brand' are incompatible.
// Type '"Alien"' is not assignable to type '"Person"'.
The above example exemplifies a way where two objects which are structurally similar can be kept distinct through the inclusion of a "brand" property __brand. This is a very important tool in a typescript developer's toolbox because often when working in a project with multiple other developers, there may be an implicit rule that we should never assign an Alien to a Person even though the rules of the type system enable us to do so. By using the brand example above, we can make that rule explicit and have the compiler tell us when that mistake has been made, thus improving code quality and preventing bugs at the source.
That being said, the above is a somewhat contrived example and the intent could be expressed better using a higher generic type, e.g LifeForm<T extends "Alien" | "Person"> or using a kind enum where the implied hidden __brand would be replaced with an explicit kind property however that is a digression.
How to use Nominal types for domain modelling
Where this "nominal" typing approach is best used is in implementing constraints on primitive types, primarily string and number. This becomes very useful as we can explicitly and safely model an arbitrary domain at the cost of some runtime overhead. Imagine we wanted to mirror the uint8 datatype from the evm in a ts/js environment safely.
First lets introduce the Nominal generic type which will be used use now and later.
type Nominal<T extends string> = { readonly [k in T as `__${k}__`]: void }
On to the example:
type Uint8 = number & Nominal<"uint8"> // number & { __uint8__: void }
This approach technically isn't nominal typing as we can invoke another type Uint8Alt the same way and both will be equivalent. What is important is that it incurs a compile time constraint on the number type enabling us to do this:
declare const x: Uint8
declare const y: number
function doubleUint8(n: Uint8): Uint8 {
return (n * 2) as Uint8 // * more on this in a sec
}
function doubleNumber(n: number): number {
return n * 2
}
doubleUint8(x) // valid
doubleNumber(x) // valid
doubleUint8(y) // invalid
doubleNumber(y) // valid
No we nicely have doubleUint8(y) throwing a compile time error and also proves Uint8's backwards compatibility with the original number type when we call doubleNumber(x). However, the doubleUint8 operates exactly as doubleNumber does and merely dangerously recasts as a Uint8. This must be replaced with a type guard function:
function isUint8(n: number): n is Uint8 {
return n >= 0 && n <= 255 && Number.isInteger(n)
}
We would rewrite doubleUint8 as:
function doubleUint8(n: Uint8): Uint8 {
const x = n * 2;
if (isUint8(x)) return x; // x is now a Uint8
throw new Error("...") // alternatively wrap by using "% 256"
}
Having the type guard isUint8 gives us the guarantee of the correctness of our code at the cost of runtime overhead. This pattern is called "runtime type checking" as the developer must add functionality to validate domain constraints. Unfortunately, doing something like this in a real project is impractical as any operation over a "nominal number" like Uint8 will always revert to a number, e.g Uint8 + number = number. An extensive library would have to be written to redefine basic numeric operations and others to make it practical.
The Address type
One place where I think the "nominal" approach would be useful is in explicitly representing Ethereum addresses distinct from the string type. Much like a uuid, an "address" is unique and in it's usage is not "operated" on as often as numbers leaving only conversions to upper/lower casing. Once a string is validated as an address it is not subject to change and so once defined, there is not going to be huge overhead in using it.
Mirroring the Uint8 definition, the Address type would look like:
export type Address = string & Nominal<"address">
For the type guard, ideally the ethers.utils.isAddress() function would be that but it is trivial to wrap around that:
const isAddress = (val: string): val is Address => ethers.utils.isAddress(val)
Again much like Uint8, the Address type when used in a function interface will throw a compiler error but preserves backwards compatibility with string.
declare const x: Address;
declare const y: string;
function getEthBalanceAddress(a: Address) { ... }
function getEthBalanceString(a: string) { ... }
getEthBalanceAddress(x) // valid
getEthBalanceString(x) // valid
getEthBalanceAddress(y) // invalid
getEthBalanceString(y) // valid
Taking this example further we can extend on Address with another nominal "tag" to provide constraint on a particular address or a set of addresses.
type DaiAddress = Address & Nominal<"dai">
const isDaiAddress = (x: Address): x is DaiAddress => x === <DAI_ADDRESS>
type ERC20Address = Address & Nominal<"erc20">
const isERC20Address = (x: Address): x is ERC20Address => MY_LIST_OF_VALID_ERC20.some(erc20Address => erc20Address === x)
The goal of using this approach is that a developer building an SDK or frontend for a smart-contract system would be able to model the collection of valid addresses and discriminate the associated business logic for the correct "address" type in each case. To be clear, the above example would be what is built on the Address type that Ethers would provide.
There are some drawbacks that we would have to be aware about if we were to include this right now. One of the typical ways a developer may use these Address types is using them as keys in objects. We can create a type Record<Address, { ... }> but the operating on such an object using Object.keys or Object.entries will reduce the key Address type to a string. I believe the same is the case for lodash and other popular tooling. It may be the case that ethers would have to provide a keys or entries methods to ergonomically preserve the Address.
Structural vs Nominal typing
One of the most discussed features in typescript is that around "non-structural (nominal)" typing. This thread goes back nearly 8 years on the subject.
For the unawares reader, typescript's type system considers a type
Ato be equivalent to typeBifBhas at least the same members ofA.The informal label for this is
duck-typingwhere there is a phrase "if it walks like a duck and quacks like a duck than it's probably a duck". This makes typescript's type system very flexible as developers can easily spin up their own interfaces where necessary and don't have to extend their dependency tree to find the exact type for an api.A nominal type system has the property where all types are unique and distinct of each other, regardless of the nature of equivalence of their properties. Typescript does not support nominal types but as in the linked thread, there are discussions surrounding how they can be added. However, even in the current version of typescript, it is possible to implement a "simulation" of them while incurring some verbosity and minor inconveniences that I will touch on.
The above example exemplifies a way where two objects which are structurally similar can be kept distinct through the inclusion of a "brand" property
__brand. This is a very important tool in a typescript developer's toolbox because often when working in a project with multiple other developers, there may be an implicit rule that we should never assign anAliento aPersoneven though the rules of the type system enable us to do so. By using the brand example above, we can make that rule explicit and have the compiler tell us when that mistake has been made, thus improving code quality and preventing bugs at the source.That being said, the above is a somewhat contrived example and the intent could be expressed better using a higher generic type, e.g
LifeForm<T extends "Alien" | "Person">or using akindenum where the implied hidden__brandwould be replaced with an explicitkindproperty however that is a digression.How to use Nominal types for domain modelling
Where this "nominal" typing approach is best used is in implementing constraints on primitive types, primarily
stringandnumber. This becomes very useful as we can explicitly and safely model an arbitrary domain at the cost of some runtime overhead. Imagine we wanted to mirror theuint8datatype from the evm in a ts/js environment safely.First lets introduce the
Nominalgeneric type which will be used use now and later.On to the example:
This approach technically isn't nominal typing as we can invoke another type
Uint8Altthe same way and both will be equivalent. What is important is that it incurs a compile time constraint on thenumbertype enabling us to do this:No we nicely have
doubleUint8(y)throwing a compile time error and also provesUint8's backwards compatibility with the originalnumbertype when we calldoubleNumber(x). However, thedoubleUint8operates exactly asdoubleNumberdoes and merely dangerously recasts as aUint8. This must be replaced with a type guard function:We would rewrite
doubleUint8as:Having the type guard
isUint8gives us the guarantee of the correctness of our code at the cost of runtime overhead. This pattern is called "runtime type checking" as the developer must add functionality to validate domain constraints. Unfortunately, doing something like this in a real project is impractical as any operation over a "nominal number" likeUint8will always revert to a number, e.gUint8 + number = number. An extensive library would have to be written to redefine basic numeric operations and others to make it practical.The
AddresstypeOne place where I think the "nominal" approach would be useful is in explicitly representing Ethereum addresses distinct from the
stringtype. Much like auuid, an "address" is unique and in it's usage is not "operated" on as often as numbers leaving only conversions to upper/lower casing. Once a string is validated as anaddressit is not subject to change and so once defined, there is not going to be huge overhead in using it.Mirroring the
Uint8definition, the Address type would look like:For the type guard, ideally the
ethers.utils.isAddress()function would be that but it is trivial to wrap around that:Again much like
Uint8, theAddresstype when used in a function interface will throw a compiler error but preserves backwards compatibility withstring.Taking this example further we can extend on
Addresswith another nominal "tag" to provide constraint on a particular address or a set of addresses.The goal of using this approach is that a developer building an SDK or frontend for a smart-contract system would be able to model the collection of valid addresses and discriminate the associated business logic for the correct "address" type in each case. To be clear, the above example would be what is built on the
Addresstype that Ethers would provide.There are some drawbacks that we would have to be aware about if we were to include this right now. One of the typical ways a developer may use these
Addresstypes is using them as keys in objects. We can create a typeRecord<Address, { ... }>but the operating on such an object usingObject.keysorObject.entrieswill reduce the keyAddresstype to a string. I believe the same is the case for lodash and other popular tooling. It may be the case that ethers would have to provide akeysorentriesmethods to ergonomically preserve the Address.