@coderspirit/nominal
Nominal
provides a powerful toolkit to apply
nominal typing on
Typescript with zero runtime overhead.
It offers three kinds of nominal types:
- Brands: Brands basically match the traditional concept of nominal typing.
Branded values can only belong to one brand, and branded variables only
accept values with that same brand.
- Flavors: Flavors are similar to brands, with one difference: flavored
variables also accept unbranded/unflavored values with the same base type.
They are very useful when dealing with "rigid" code generators or other cases
where we would be forced to write tons of mappings just to content the type
checker.
- Properties: They are very useful to express things like logical and
mathematical properties, but also to implement a weak form of
dependent types.
While each type can only have either a brand or a flavor, we can easily
combine brands or flavors with properties.
Install instructions
Node
# With NPM
npm install --save-dev @coderspirit/nominal
# Or with PNPM
pnpm add --save-dev @coderspirit/nominal
# Or with Yarn:
yarn add --dev @coderspirit/nominal
[!TIP]
Note that if you are developing an application, it is fine to install
@coderspirit/nominal
as a development dependency, but it might be necessary
to install it as a normal or peer dependency in case you are developing your
own libraries based on it.
Brands
import { WithBrand } from '@coderspirit/nominal'
type Email = WithBrand<string, 'Email'>
type Username = WithBrand<string, 'Username'>
const email: Email = 'admin@acme.com' as Email
const user: Username = 'admin' as Username
const text: string = email
const anotherText: string = user
const eMail: Email = 'admin@acme.com'
const mail: Email = user
Advice
- Although we perform a "static cast" here, this should be done only when:
- the value is a literal (as in the example)
- in validation, sanitization and/or anticorruption layers.
- One way to protect against other developers "forging" the type is to use
symbols instead of strings as property keys or property values when defining
the new nominal type.
Flavors
import { WithFlavor } from '@coderspirit/nominal'
type Email = WithFlavor<string, 'Email'>
type Username = WithFlavor<string, 'Username'>
const email: Email = 'admin@acme.com' as Email
const user: Username = 'admin' as Username
const text: string = email
const anotherText: string = user
const eMail: Email = 'admin@acme.com'
const mail: Email = user
Advice
- Although we perform a "static cast" here, this should be done only when:
- the value is a literal (as in the example)
- in validation, sanitization and/or anticorruption layers.
- One way to protect against other developers "forging" the type is to use
symbols instead of strings as property keys or property values when defining
the new nominal type.
Faster brands and flavors
The types WithBrand
and WithFlavor
, although quite simple in their purpose,
hide a quite complex machinery that exists for the sole purpose of maintaining
full compatibility with other more complex types such as WithProperty
.
Most times we won't really need to rely on such complex mechanisms because we
apply WithBrandh
and WithFlavor
to basic types. So, if we want to minimize
our compilation types, we can chose a simpler and faster implementation:
import {
FastBrand,
FastFlavor,
WithBrand,
WithFlavor
} from '@coderspirit/nominal'
type SlowEmailType = WithBrand<string, 'Email'>
type FastEmailType = FastBrand<string, 'Email'>
type SlowPhoneNumberType = WithFlavor<string, 'PhoneNumber'>
type FastPhoneNumberType = FastFlavor<string, 'PhoneNumber'>
Properties
Introduction
To define a new type with a property, we can do:
import { WithProperty } from '@coderspirit/nominal'
type Even = WithProperty<number, 'Parity', 'Even'>
const myEven: Even = 42 as Even
If we want to use the properties as simple tags, we can omit the property value,
and it will implicitly default to true
, although it's less flexible:
import { WithProperty } from '@coderspirit/nominal'
type Positive = WithProperty<number, 'Positive'>
const myPositive: Positive = 1 as Positive
Interesting properties
WithProperty
is additive, commutative and idempotent.- The previous point means that we don't have to worry about the order of
composition, we won't suffer typing inconsistencies because of that.
WithProperty
can be combined in two ways, which are completely compatible:
- "Classic"
&
type operator:
type PositiveEven = WithProperty<number, 'Parity', 'Even'> & WithProperty<number, 'Positive'>
- Nesting types:
type PositiveEven = WithProperty<WithProperty<number, 'Positive'>, 'Parity', 'Even'>
Advice
- Although we perform a "static cast" here, this should be done only when:
- the value is a literal (as in the example)
- in validation, sanitization and/or anticorruption layers.
- One way to protect against other developers "forging" the type is to use
symbols instead of strings as property keys or property values when defining
the new nominal type.
Crazy-level strictness
If we want, we can even define "property types", to ensure that we don't set
invalid values:
import { PropertyTypeDefinition, WithStrictProperty } from '@coderspirit/nominal'
type Parity = PropertyTypeDefinition<'Parity', 'Even' | 'Odd'>
type Even = WithStrictProperty<number, Parity, 'Even'>
type Wrong = WithStrictProperty<number, Parity, 'Seven'>
Advanced use cases (pseudo dependent types)
Properties can be preserved across function boundaries
This feature can be very useful when we need to verify many properties for the
same value and we don't want to lose this information along the way as the value
is passed from one function to another.
function throwIfNotEven<T extends number>(v: T): WithProperty<T, 'Parity', 'Even'> {
if (v % 2 == 1) throw new Error('Not Even!')
return v as WithProperty<T, 'Even'>
}
function throwIfNotPositive<T extends number>(v: T): WithProperty<T, 'Sign', 'Positive'> {
if (v <= 0) throw new Error('Not positive!')
return v as WithProperty<T, 'Positive'>
}
const v1 = 42
const v2 = throwIfNotEven(v1)
const v3 = throwIfNotPositive(v2)
Chosing what properties to preserve across function boundaries
In the previous example, we could add many properties because we were just
making assertions about the values. When we transform the passed values, we must
be more careful about what we preserve.
As a simple example of what we are telling here, we can see that adding 1
to a
numeric variable would flip its parity, so in that case we wouldn't want to keep
that property on the return value.
type Even<N extends number = number> = WithProperty<N, 'Parity', 'Even'>
type Odd<N extends number = number> = WithProperty<N, 'Parity', 'Odd'>
type PlusOneResult<N> = KeepProperties<
N extends Even
? KeepPropertyIfValueMatches<Odd<N>, 'Sign', 'Positive'>
: N extends Odd
? KeepPropertyIfValueMatches<Even<N>, 'Sign', 'Positive'>
: KeepPropertyIfValueMatches<N, 'Sign', 'Positive'>,
'Sign' | 'Parity'
>
function plusOne<N extends number>(v: N): PlusOneResult<N> {
return v + 1 as PlusOneResult<N>
}