io-ts icon indicating copy to clipboard operation
io-ts copied to clipboard

allow refine to have different error messages for different problems

Open osdiab opened this issue 4 years ago • 5 comments

🚀 Feature request

Current Behavior

the function you pass to D.refine() (as well as t.brand()) is a type guard function, returning true or false. This means that there isn't an API to have different error messages for different reasons why the input value may be invalid.

For example, if I have a Username type such that it must be at least 2 characters, at most 20, and match some allowed character set - but ultimately is still a string - I can't use the refine nor brand types to distinguish these issues.

Desired Behavior

I would love to be able to define this example as a Username refinement, such that if the string is too short, I get an error message saying that; if its too long I get an error message saying that, and so on.

Suggested Solution

D.refine()'s input function can either return a boolean as it does now, or perhaps can also return D.failure() outputs as well.

Alternatively can return strings that are error messages, or some other mechanism for returning these errors.

Who does this impact? Who is this for?

People who use refinements and want to have nice error messages.

Describe alternatives you've considered

I haven't tried these with the new 2.2 API so just translating my thought process with the old t ones, so this might not work.

I can work around this by using D.parse with the branded type, but I thought it was counterintuitive that refine() couldn't achieve this on its own.

export interface UsernameBrand {
  readonly Username: unique symbol
}

export type Username = string & UsernameBrand

export const UsernameDecoder: D.Decoder<Username> = pipe(
  D.string,
  D.parse((s) =>
    s.length < 2 
      ? D.failure(s, 'Username', 'too short')
      : s.length > 20
        ? D.failure(s, 'Username', 'too long')
        : USERNAME_REGEX.test(s)
          ? D.failure(s, 'Username', 'bad characters')
          : D.success(n);
  )
)

I can also make multiple different refinement types with error messages using the withMessage helper and chain them together, but it seemed silly to need to make multiple intermediate brand types that I have no real intention to use, for each of the potential issues.

interface UsernameNotTooLongBrand {
  readonly UsernameNotTooLong: unique symbol
}
type UsernameNotTooLong = string & UsernameNotTooLongBrand;

interface UsernameValidCharactersBrand {
  readonly UsernameValidCharacters: unique symbol
}
type UsernameValidCharacters = string & UsernameValidCharactersBrand;

export const UsernameDecoder: D.Decoder<Username> = pipe(
  withMessage(NonEmptyString, 'too short'),
  withMessage(D.refine((s): s is UsernameNotTooLong => s.length <= 20, () => "too long"),
  withMessage(D.refine((s): s is UsernameValidCharacters => USERNAME_REGEX.test(s), () => "bad characters"),
);
export type Username = D.TypeOf<typeof UsernameDecoder>;

Finally I can use the withMessage helper and redundantly create the same validation logic as that of the refinement in its callback, but I don't want to duplicate logic like that.

/**
 * @returns error string if invalid, null otherwise
 */
function validateUsername(s: string): string | null {
  return s.length < 2 
    ? "too short" 
    : s.length > 20 
      ? "too long" 
      : !USERNAME_REGEX.test(s) ? "bad characters" : null;
}

export interface UsernameBrand {
  readonly Username: unique symbol
}

export type Username = string & UsernameBrand;
export type UsernameDecoder = withMessage(
  pipe(
    D.string,
    D.refine((s): s is Username => validateUsername(s) === null)
  ),
  validateUsername // this codec will now run my validation twice to get the proper error messages
)

osdiab avatar Jun 29 '20 08:06 osdiab

D.refine()'s input function can either return a boolean as it does now, or perhaps can also return D.failure() outputs as well.

It doesn't symply return a boolean, it returns an a is B and I actually need this syntax so you can refine the type from A to B.

I don't think your use case requires an API change though, a few options:

Option 1

import { pipe } from 'fp-ts/lib/pipeable'
import * as D from 'io-ts/lib/Decoder'
import * as E from 'fp-ts/lib/Either'

const USERNAME_REGEX = /(a|b)*d/

export const Username = pipe(
  D.string,
  D.refine((s): s is string => s.length >= 2, 'at least 2 characters long'),
  D.refine((s): s is string => s.length <= 20, 'at most 20 characters long'),
  D.refine((s): s is string => USERNAME_REGEX.test(s), 'a string matching some allowed character set')
)

const stringify: <A>(e: E.Either<D.DecodeError, A>) => string = E.fold(D.draw, (a) => JSON.stringify(a, null, 2))

console.log(stringify(Username.decode(null))) // cannot decode null, should be string
console.log(stringify(Username.decode('1'))) // cannot decode "1", should be at least 2 characters long
console.log(stringify(Username.decode('123456789012345678901'))) // cannot decode "123456789012345678901", should be at most 20 characters long
console.log(stringify(Username.decode('abc'))) // cannot decode "abc", should be a string matching some allowed character set

Option 2

import * as KD from 'io-ts/lib/KleisliDecoder'

const StringMin2 = KD.fromRefinement((s: string): s is string => s.length >= 2, 'at least 2 characters long')
const StringMax20 = KD.fromRefinement((s: string): s is string => s.length <= 20, 'at most 20 characters long')
const StringRegExp = KD.fromRefinement(
  (s: string): s is string => USERNAME_REGEX.test(s),
  'a string matching some allowed character set'
)

export const Username2 = pipe(
  D.string,
  D.compose(pipe(StringMin2, KD.intersect(StringMax20), KD.intersect(StringRegExp)))
)

Option 3

function validateUsername(s: string): string | null {
  return s.length < 2
    ? 'at least 2 characters long'
    : s.length > 20
    ? 'at most 20 characters long'
    : !USERNAME_REGEX.test(s)
    ? 'a string matching some allowed character set'
    : null
}

const Username3: D.Decoder<string> = pipe(
  D.string,
  D.compose({
    decode: (s) => {
      const expected = validateUsername(s)
      return expected !== null ? D.failure(s, expected) : D.success(s)
    }
  })
)

gcanti avatar Jun 29 '20 11:06 gcanti

I like the solutions you've posted - though I guess how might I make it so that ultimately I end up with a refinement of string instead of just a string itself? I guess for option 1 maybe I can just pipe another refinement whose type guard always returns true, but casts the type to my example UsernameBrand? But that seems like a not-obvious hack to force a type cast, for some reason it rubs me weird.

osdiab avatar Jun 30 '20 07:06 osdiab

Personally I'd first define some re-utilizable decoder constructors (which could end up in a library like io-ts-types when the experimental APIs are stable)

import { pipe } from 'fp-ts/lib/pipeable'
import * as D from 'io-ts/lib/Decoder'

//
// io-ts-types code?
//

interface MinBrand<N extends number> {
  readonly Min: unique symbol
  readonly min: N
}

type Min<N extends number> = string & MinBrand<N>

const min = <N extends number>(min: N) =>
  D.fromRefinement((s: string): s is Min<N> => s.length >= min, `at least ${min} characters long`)

interface MaxBrand<N extends number> {
  readonly Max: unique symbol
  readonly max: N
}

type Max<N extends number> = string & MaxBrand<N>

const max = <N extends number>(max: N) =>
  D.fromRefinement((s: string): s is Max<N> => s.length <= max, `at most ${max} characters long`)

const between = <Low extends number, Hi extends number>(low: Low, hi: Hi) => pipe(min(low), D.intersect(max(hi)))

//
// app code
//

interface UsernameBrand {
  readonly Username: unique symbol
}

type Username = string & Min<2> & Max<20> & UsernameBrand

const USERNAME_REGEX = /(a|b)*d/

export const Username: D.Decoder<unknown, Username> = pipe(
  D.string,
  D.compose(between(2, 20)),
  D.refine((s): s is Username => USERNAME_REGEX.test(s), 'Username')
)

gcanti avatar Jun 30 '20 10:06 gcanti

@gcanti is there a way to encode the min/max amount of characters in the Decoder itself? Like a would be possible in the old way where I could write my own Type and store the information in there?

An example in the old implementation that decodes this information could look like this

import { pipe } from 'fp-ts/lib/function'
import * as t from 'io-ts'
import * as E from 'fp-ts/lib/Either'

interface StringMaxLengthBrand<N extends number> {
  readonly StringMaxLength: unique symbol
  readonly max: N
}

export type StringMaxLength<N extends number> = string & StringMaxLengthBrand<N>

export class StringMaxLengthType<N extends number> extends t.Type<string & StringMaxLength<N>, string, unknown> {
  readonly _tag: 'StringMaxLengthType' = 'StringMaxLengthType'
  constructor(
    name: string,
    is: StringMaxLengthType<N>['is'],
    validate: StringMaxLengthType<N>['validate'],
    encode: StringMaxLengthType<N>['encode'],
    // Like in the InterfaceType with `props` I am able to store meta information for this type
    readonly max: N
  ) {
    super(name, is, validate, encode)
  }
}

export interface StringMaxLengthC<N extends number> extends StringMaxLengthType<N> {}

export function stringMaxLength<N extends number>(max: N): StringMaxLengthC<N> {
  return new StringMaxLengthType(
    `StringMaxLength<${max}>`,
    (u): u is string & StringMaxLength<N> => t.string.is(u) && u.length <= max,
    (u, c) =>
      pipe(
        t.string.validate(u, c),
        E.chain(u => (u.length <= max ? t.success(u as string & StringMaxLength<N>) : t.failure(u, c)))
      ),
    t.identity,
    max
  )
}

mlegenhausen avatar Sep 03 '20 07:09 mlegenhausen

@mlegenhausen you can extend the base Decoder interface

interface MaxDecoder<N extends number> extends D.Decoder<string, Max<N>> {
  readonly max: N
}

const max = <N extends number>(max: N): MaxDecoder<N> => ({
  ...D.fromRefinement((s: string): s is Max<N> => s.length <= max, `at most ${max} characters long`),
  max
})

(but you loose the meta informations as soon as you compose MaxDecoder with something else I guess)

gcanti avatar Sep 04 '20 06:09 gcanti