loading...

Functional design: smart constructors

gcanti profile image Giulio Canti Updated on ・2 min read

Functional design (6 Part Series)

1) Functional design: combinators 2) Functional design: how to make the `time` combinator more general 3 ... 4 3) Functional design: tagless final 4) Functional design: smart constructors 5) Functional design: TDD in TypeScript (aka abusing `declare`) 6) Functional design: Algebraic Data Types

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

  1. define a type R which represents the refinement
  2. do not export a constructor for R
  3. 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:

  1. 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
  1. do not export a constructor for NonEmptyString
// DON'T do this
export function nonEmptyString(s: string): NonEmptyString { ... }
  1. do export a smart constructor make: (s: string) => Option<NonEmptyString>
import { Option, none, some } from 'fp-ts/lib/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/lib/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

Functional design (6 Part Series)

1) Functional design: combinators 2) Functional design: how to make the `time` combinator more general 3 ... 4 3) Functional design: tagless final 4) Functional design: smart constructors 5) Functional design: TDD in TypeScript (aka abusing `declare`) 6) Functional design: Algebraic Data Types

Posted on by:

Discussion

markdown guide
 

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:

type PersonDto = {
  name: string,
  email: string
}

and returns a domain type (or some validation errors), something like this:

type Person = {
  name: ValidName, // created by a smart constructor
  email: ValidEmail // created by a smart constructor
}

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. πŸ‘Œ