gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.68k stars 331 forks source link

Cannot generically create type'd union from string literals for an enum #678

Open tsheaff opened 1 year ago

tsheaff commented 1 year ago

🐛 Bug report

Current Behavior

import * as t from 'io-ts';

const myEnumValues = [
  'value_a',
  'value_b',
  'value_c',
] as const;
export type MyEnumValue = typeof myEnumValues[number];
export const tMyEnumValue = t.union(myEnumValues.map((value) => t.literal(value)));

I would expect tMyEnumValue to be equivalent to the following:

export const tMyEnumValue = t.union([t.literal('value_a'), t.literal('value_b'), t.literal('value_c')]);

However, the explicit string literals work well while the maps do not. I get the following TS compiler error:

Argument of type 'LiteralC<"value_a" | "value_b" | "value_c">[]' is not assignable to parameter of type '[Mixed, Mixed, ...Mixed[]]'.
  Source provides no match for required element at position 0 in target.ts(2345)

Here's a screenshot in VSCode

Screenshot 2022-11-30 at 20 43 33

Expected behavior

t.union(stringsArray.map(t.literal) compiles and works properly.

Reproducible example

See above

Suggested solution(s)

I'm not a deep expert on the io-ts library, so I don't yet know how to solve this. I'm happy to learn more tho and contribute a PR if there's someone on the team willing to point me in the right direction.

Or perhaps I'm missing something here and there's an obvious way to do what I'm trying to do here.

Your environment

Which versions of io-ts are affected by this issue? Did this work in previous versions of io-ts?

Software Version(s)
io-ts io-ts@2.2.16
fp-ts fp-ts@2.12.1
TypeScript typescript@4.6.3
mlegenhausen commented 1 year ago

The problem is that union accepts at least two elements else creating a union makes no sense as you could for one element use the codec directly and you need to define what needs to happen when the array is empty.

In this case also the const does not make sure that you have at least two element as the map function converts your triple back to an array. You can get this working by using map only on the "rest" of your array and make sure that you have at least two elements. This looks something like this:

import * as t from 'io-ts';

const myEnumValues = [
  'value_a',
  'value_b',
  'value_c',
] as const;
export type MyEnumValue = typeof myEnumValues[number];
const [first, second, ...rest]  = myEnumValues;
export const tMyEnumValue = t.union([
  t.literal(first),
  t.literal(second), 
  ...rest.map((value) => t.literal(value))
]);

If you want to support less that two elements but at least one you could write your own union function that

import * as t from 'io-ts'
import * as NEA from 'fp-ts/NonEmptyArray'

export const relaxedUnion = <A extends NEA.NonEmptyArray<t.Mixed>>(
  codecs: A,
  name?: string,
): t.Type<t.TypeOf<A[number]>, t.OutputOf<A[number]>> =>
  pipe(
    codecs,
    NEA.matchLeft((head1, tail) =>
      pipe(
        tail,
        A.matchLeft(
          () => head1,
          (head2, tail2) => t.union([head1, head2, ...tail2], name),
        ),
      ),
    ),
  );