Io-ts: [RFC] deprecate refinement (and Integer)

Created on 20 Jan 2019  路  10Comments  路  Source: gcanti/io-ts

Motivation: refinements are inherently unsafe

Example

const PositiveInt = t.refinement(t.Integer, n => n > 0, 'PositiveInt')

const Person = t.type({
  name: t.string,
  age: PositiveInt
})

type Person = t.TypeOf<typeof Person>

const person: Person = {
  name: 'name',
  age: -1.2 // <= no errors
}

I propose to deprecate refinement in favour of a brand combinator

Definition

declare const _brand: unique symbol

export interface Brand<B> {
  readonly [_brand]: B
}

export interface BrandC<C extends t.Any, B>
  extends t.RefinementType<C, t.TypeOf<C> & Brand<B>, t.OutputOf<C>, t.InputOf<C>> {}

// replaces refinement
export const brand = <C extends t.Any, B extends string>(
  codec: C,
  predicate: Predicate<t.TypeOf<C>>,
  name: B
): BrandC<C, B> => {
  return t.refinement(codec, predicate, name)
}

// replaces Integer
export const Int = brand(number, n => n % 1 === 0, 'Int')

Usage

// const Int: BrandC<t.NumberC, "Int">
const Int = brand(t.number, n => n % 1 === 0, 'Int')
// type Int = number & Brand<"Int">
type Int = t.TypeOf<typeof Int>

const Positive = brand(t.number, n => n > 0, 'Positive')
// type Positive = number & Brand<"Positive">
type Positive = t.TypeOf<typeof Positive>

const PositiveInt = t.intersection([Int, Positive])
// type PositiveInt = number & Brand<'Int'> & Brand<'Positive'>
type PositiveInt = t.TypeOf<typeof PositiveInt>

import { PathReporter } from 'io-ts/lib/PathReporter'

console.log(PathReporter.report(PositiveInt.decode(1.1)))
// [ 'Invalid value 1.1 supplied to : (Int & Positive)/0: Int' ]
console.log(PathReporter.report(PositiveInt.decode(-1)))
// [ 'Invalid value -1 supplied to : (Int & Positive)/1: Positive' ]

const Person = t.type({
  name: t.string,
  age: PositiveInt
})

type Person = t.TypeOf<typeof Person>

const p: Person = {
  name: 'Giulio',
  age: -1.2 // Type 'number' is not assignable to type 'Brand<"Int">
}

The brand signature is almost identical to refinement (the name argument is required though).

The migration path should be straightforward and incremental: you can switch to brand, one refinement at the time, and fix the possible type-checking errors that could happen.

All 10 comments

Yeah "ad-hoc branding" is something I'm doing a lot now, pending https://github.com/Microsoft/TypeScript/issues/202 resulting in something less boilerplatey, like type PositiveInt = unique number. I've been casting to the brand in my io-ts types, so this combinator feels pretty natural to me.

Yeah "ad-hoc branding" is something I'm doing a lot now

@leemhenson that's great, your early feedback would be much appreciated, you can try the release candidate by running npm i gcanti/io-ts#1.8.0-lib

Works as expected and saves me a bit of boilerplate! 馃憤

I'm using refined types a lot, but had to implement own type and combinator:

import { AnyNewtype, Newtype } from "newtype-ts"
import * as t from "io-ts"

export type Refined<_URI extends { readonly [K in symbol | string | number]: symbol }, A> =
  A & Newtype<_URI, A>

export function refine<T extends AnyNewtype, RT extends t.Type<T["_A"]>>(
  rt: RT,
  isT: (val: t.OutputOf<RT>) => val is T,
  name: string
): t.Type<T, T, t.InputOf<RT>> {
  return new t.Type(
    name,
    (val: t.InputOf<RT>): val is T => rt.is(val) && isT(val),
    (val, ctx) => rt.decode(val).filterOrElseL(isT, _ => [t.getValidationError(_, ctx)]),
    t.identity
  )
}

But the issue with this is that it's _legal_ to access the _URI property and then compiler will not scream unique symbol field name fixes this and is also shorter to write type definitions.

It may be also useful to have a Refined (or Branded) type exported by io-ts so it can be used to declare types explicitely*, ex:

export type Refined<A, Tag extends string> = A & Brand<Tag>

type Int = t.Refined<number, "Int">

_*I'm often declaring types explicitly rather than extracting with t.TypeOf, to avoid 'uge definition files (I know it was improved in latest versions: https://github.com/gcanti/io-ts/issues/165)_

Hope to see this functionality in next minor release 馃憤.

It may be also useful to have a Refined (or Branded) type exported by io-ts so it can be used to declare types explicitely

@lostintime what's the use case? Let's say we define Positive

const Positive = t.brand(t.number, n => n > 0, 'Positive')

type Positive = t.TypeOf<typeof Positive>
/*
same as
type Positive = number & t.Brand<"Positive">
*/

How would you use a Branded type?

export type Branded<A, B> = A & Brand<B>

@gcanti as I mentioned - I'm currently defining types explicitly, then adding decoders for them, to get cleaner error messages, and less verbose definition files, ex:

type UserId = t.Branded<string, "UserId">

const UserId: t.Type<UserId> = t.brand(t.number, _ => true, "UserId") 

type User = {
  userId: UserId
  name: string
}

const User: t.Type<User> = t.interface({
  userId: UserId,
  name: t.string
})

Maybe that's not the case for t.brand (or it depends on parent type verbosity), but in order to keep code style consistent I think I'd be using same for refined too.

My approach have some other pitfalls also - for types with optional values - you may forget to add some fields to decoders and compiler will not complain :(, that's why I was searching long time for a way to pass explicit types parameters to decoders also, ex:

const User: t.Type<User> = t.interface<User>({...})

but it doesn't work this way, full decoder type is expected.

For example this definition:

export const Person = t.interface({
  name: t.intersection([
    t.interface({
      firstName: t.string,
      lastName: t.string
    }),
    t.partial({
      middleName: t.string
    })
  ]),
  cityName: t.string
})

export const MyPersons = t.interface({
  something: t.string,
  persons: t.array(Person)
})

generates this definition (using [email protected]):

export declare const Person: t.TypeC<{
    name: t.IntersectionC<[t.TypeC<{
        firstName: t.StringC;
        lastName: t.StringC;
    }>, t.PartialC<{
        middleName: t.StringC;
    }>]>;
    cityName: t.StringC;
}>;

export declare const MyPersons: t.TypeC<{
    something: t.StringC;
    persons: t.ArrayC<t.TypeC<{
        name: t.IntersectionC<[t.TypeC<{
            firstName: t.StringC;
            lastName: t.StringC;
        }>, t.PartialC<{
            middleName: t.StringC;
        }>]>;
        cityName: t.StringC;
    }>>;
}>;

And it gets worse when composing types.

Ah, so you are not satisfied by the type of the codec itself...

you are writing the schema twice though

// 1)
type User = {
  userId: number
  name: string
}

// 2)
const User: t.Type<User> = t.type({
  userId: t.number,
  name: t.string
})

What about this pattern?

const _User = t.type({
  userId: t.number,
  name: t.string
})
interface User extends t.TypeOf<typeof _User> {}
const User: t.Type<User> = _User

Works with brands too

const _UserId = t.brand(t.number, _ => true, 'UserId')
type UserId = t.TypeOf<typeof _UserId>
type UserIdO = t.OutputOf<typeof _UserId>
const UserId: t.Type<UserId, UserIdO> = _UserId

The described pattern solves the issue with verbose definitions and makes code less vulnerable to human factor issue I described above, which is much much better.

There are still few pros to using explicit types:

You get better IDE hints:

screen shot 2019-02-04 at 11 33 18

vs:

screen shot 2019-02-04 at 11 34 59

and field comments:

screen shot 2019-02-04 at 11 34 44

And you can also define decoders for _external_ types, I mean - there is still place for the way I'm using it.

Anyway, this is probably not the best place to discuss this issue :)

On the _refined_ topic - I don't think this should stop the progress with new brand combinator, I can add type alias locally (or even inline it as T & t.Brand<"Name">) until (_and if_) it will added to the library.

this is probably not the best place to discuss this issue

Indeed.. one last observation though: I used interface in my previous example because is less verbose but if you want to see the details you can use a type alias instead

const _User = t.intersection([
  t.type({
    userId: t.number,
    /**
     * Full user name
     */
    name: t.string
  }),
  t.partial({
    something: t.string,
    anything: t.number
  })
])

type User = t.TypeOf<typeof _User> // a type alias instead of an interface

const User: t.Type<User> = _User

On hover

onhover

Comments work already both with interfaces and type aliases

comments

On the refined topic - I don't think this should stop the progress with new brand combinator, I can add type alias locally (or even inline it as T & t.Brand<"Name">) until (and if) it will added to the library.

:+1: thanks for your feedback

Was this page helpful?
0 / 5 - 0 ratings