gcanti / io-ts

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

[Question] Typing a generic mapped union #694

Closed silasdavis closed 1 year ago

silasdavis commented 1 year ago

I would like to type a function that returns a union based on some generic arguments containing a schema.

Below is a simplified example:

const schema1 = {
  n: 'foo',
  s: t.type({ foo: t.string }),
} as const;

const schema2 = {
  n: 'bar',
  s: t.type({ bar: t.string }),
} as const;

type Schema<N extends string, S extends t.Mixed> = {
  n: N;
  s: S;
};

function unionClosure<SS extends [[string, t.Mixed], [string, t.Mixed], ...[string, t.Mixed][]]>(schemas: {
  [K in keyof SS]: Schema<SS[K][0], SS[K][1]>;
}) {
  return schemas.map(({ n, s }) => t.type({ n: t.literal(n), s }));
}

const desiredUnion = t.union([
  t.type({ n: t.literal('foo'), s: t.type({ foo: t.string }) }),
  t.type({ n: t.literal('bar'), t: t.type({ bar: t.string }) }),
]);

// Types are not narrowed here
const actualUnion = unionClosure([schema1, schema2]);

I was hoping to use variadic types and a map to get the same types as if I had written it out literally. I'm just wondering if this is actually possible?

(I appreciate in the above I am not maintaining the non-empty array property that t.union expects, but I could work around that in various ways if it was actually possible to type unionClosure as I want it at all)

silasdavis commented 1 year ago

The following seems to work:

import * as t from 'io-ts';

type Schema<N extends string, A> = {
  n: N;
  s: t.Type<A>;
};

const schema1 = {
  n: 'foo',
  s: t.type({ foo: t.string }),
} as const;

const schema2 = {
  n: 'bar',
  s: t.type({ bar: t.string }),
} as const;

const schema3 = {
  n: 'baz',
  s: t.type({ baz: t.string }),
} as const;

export function maybeUnion<T extends t.Type<any>[]>(schemas: T) {
  if (schemas.length === 0) {
    throw new Error('Cannot union zero schemas');
  }
  if (schemas.length === 1) {
    return schemas[0];
  }

  return t.union([schemas[0], schemas[1], ...schemas.slice(2)]);
}

// Distribute over union
export type OutputSchema<T> = T extends Schema<infer N, infer S>
  ? t.Type<{
      n: N;
      s: S;
    }>
  : never;

// Handle the branching between whehter we have one or more than one EventSchema types
export type OutputSchemas<T extends any[]> = T extends [infer First, infer Second, ...(infer Rest)[]]
  ? t.UnionC<[OutputSchema<First>, OutputSchema<Second>, ...OutputSchema<Rest>[]]>
  : T extends [infer First]
  ? OutputSchema<First>
  : never;

type SchemaTuple = [string, any];

function unionClosure<
  TS extends [SchemaTuple, ...SchemaTuple[]],
  SS extends { [K in keyof TS]: Schema<TS[K][0], TS[K][1]> },
>(schemas: SS): OutputSchemas<SS> {
  return maybeUnion(schemas.map(({ n, s }) => t.type({ n: t.literal(n), s }))) as OutputSchemas<SS>;
}

const actualUnion = unionClosure([schema2, schema1]);

type Foo = OutputSchemas<[typeof schema1, typeof schema2]>;