Sometimes you need guarantees about the values in your program beyond what can be accomplished with the usual type system checks. Smart constructors can be used for this purpose.
The Problem
interface Person {
name: string
age: number
}
function person(name: string, age: number): Person {
return { name, age }
}
const p = person('', -1.2) // no error
As you can see, string
and number
are broad types. How can I define a non empty string? Or positive numbers? Or integers? Or positive integers?
More generally:
how can I define a refinement of a type
T
?
The recipe
- define a type
R
which represents the refinement - do not export a constructor for
R
- do export a function (the smart constructor) with the following signature
make: (t: T) => Option<R>
A possible implementation: branded types
A branded type is a type T
intersected with a unique brand
type BrandedT = T & Brand
Let's implement NonEmptyString
following the recipe above:
- define a type
NonEmptyString
which represents the refinement
export interface NonEmptyStringBrand {
readonly NonEmptyString: unique symbol // ensures uniqueness across modules / packages
}
export type NonEmptyString = string & NonEmptyStringBrand
- do not export a constructor for
NonEmptyString
// DON'T do this
export function nonEmptyString(s: string): NonEmptyString { ... }
- do export a smart constructor
make: (s: string) => Option<NonEmptyString>
import { Option, none, some } from 'fp-ts/Option'
// runtime check implemented as a custom type guard
function isNonEmptyString(s: string): s is NonEmptyString {
return s.length > 0
}
export function makeNonEmptyString(s: string): Option<NonEmptyString> {
return isNonEmptyString(s) ? some(s) : none
}
Let's do the same thing for the age
field
export interface IntBrand {
readonly Int: unique symbol
}
export type Int = number & IntBrand
function isInt(n: number): n is Int {
return Number.isInteger(n) && n >= 0
}
export function makeInt(n: number): Option<Int> {
return isInt(n) ? some(n) : none
}
Usage
interface Person {
name: NonEmptyString
age: Int
}
function person(name: NonEmptyString, age: Int): Person {
return { name, age }
}
person('', -1.2) // static error
const goodName = makeNonEmptyString('Giulio')
const badName = makeNonEmptyString('')
const goodAge = makeInt(45)
const badAge = makeInt(-1.2)
import { option } from 'fp-ts/Option'
option.chain(goodName, name => option.map(goodAge, age => person(name, age))) // some({ "name": "Giulio", "age": 45 })
option.chain(badName, name => option.map(goodAge, age => person(name, age))) // none
option.chain(goodName, name => option.map(badAge, age => person(name, age))) // none
Conclusion
This seems to just pushing the burden of the runtime check to the caller. That's fair, but the caller in turn might push this burden up to its caller, and so on until you reach the system boundary, where you should do input validation anyway.
For a library that makes easy to do runtime validation at the system boundary and supports branded types, check out io-ts
Top comments (4)
Hey Giulio, thanks for the great article!
I've been doing type-driven-design lately and I usually start by defining my domain logic with types. These types are usually made of refined types created by using smart constructors so I have full control on what's allowed in the domain logic.
For every type I have a "toDomain" function that takes a serializable dto (the interface to the "outside" world), let's say:
and returns a domain type (or some validation errors), something like this:
This function involves quite a lot of code to transform and compose together the output from all the constructors (usually an Either) to get back a Validation so I was thinking about using use io-ts to accomplish the same thing.
In order to do that I would need to write all my types, even the domain ones, directly with io-ts which is something I'm not really sure about. What's your take on this?
If you are not comfortable with deriving your domain models from io-ts values (understandable) the other option is code generation.
Also check out io-ts-codegen
Interesting, never thought about returning
Option
for a "constructor" instead of throwing.It makes perfect sense, but I was too used to throwing being the default option in real (
new
keyword) constructors that I extended that to everything, even primitives wrapped in newtypes.These are called opaque types in functional world. 👌