gcanti/io-ts

Parsing open-ended unions

tibbe opened this issue ยท 3 comments

tibbe commented

๐Ÿš€ Feature request

This is a bit of a long shot, as I don't know if this is a fundamental problem in TypeScript. Still this issue ought to be common in parsing JSON from a backend so perhaps it's been addressed in io-ts.

Current Behavior

A common problem in frontend development is handling version skew between the backend and the frontend. This happens e.g. when a released mobile app expects the backend to return one of a union of possible items and the backend adds a new item to the union.

Example: the backend can return either a Circle or a Rectangle object, represented using a Shape = Circle | Rectangle union distinguished by a kind field. The server adds a Hexagon shape, distinguished by kind: 'hexagon' but the frontend code hasn't been updated to handle this.

Modelling the problem in io-ts:

import * as t from 'io-ts';
import * as tPromise from 'io-ts-promise';

const Circle = t.type({kind: t.literal('circle'), radius: t.number});
type Circle = t.TypeOf<typeof Circle>;

const Rectangle = t.type({kind: t.literal('rectangle'), height: t.number, width: t.number});
type Rectangle = t.TypeOf<typeof Rectangle>;

const Shape = t.union([Circle, Rectangle]);
type Shape = t.TypeOf<typeof Shape>;

const f = async (json: any) => {
  const o = await tPromise.decode(Shape, json);
  switch (o.kind) {
    case 'circle':
      console.log('circle:', o.radius);
      break;
    case 'rectangle':
      console.log('rectangle:', o.height, o.width);
      break;
  }
}

f({kind: 'circle', radius: 2.0});
f({kind: 'rectangle', height: 2.0, width: 3.0});
f({kind: 'hexagon', side: 2.0});  // Parsing fails.

The above example fails when parsing a JSON value with unknown kind tag.

Desired Behavior

We need to be able to express a parser combinator (and thus also the type generated by the generator) that preferentially parses into known types and falls back to a UnknownRecord or similar (although we want the unknown record to contain the discriminator field still).

Suggested Solution

This is a non-working solution (OK at runtime but with type errors) but it points in a possible direction:

import * as t from 'io-ts';
import * as tPromise from 'io-ts-promise';

const Circle = t.type({kind: t.literal('circle'), radius: t.number});
type Circle = t.TypeOf<typeof Circle>;

const Rectangle = t.type({kind: t.literal('rectangle'), height: t.number, width: t.number});
type Rectangle = t.TypeOf<typeof Rectangle>;

const UnknownShape = t.intersection([t.type({kind: t.string}), t.UnknownRecord]);
type UnknownShape = t.TypeOf<typeof UnknownShape>;

const Shape = t.union([Circle, Rectangle, UnknownShape]);
type Shape = t.TypeOf<typeof Shape>;

const f = async (json: any) => {
  const o = await tPromise.decode(Shape, json);
  switch (o.kind) {
    case 'circle':
      console.log('circle:', o.radius);
      break;
    case 'rectangle':
      console.log('rectangle:', o.height, o.width);
      break;
    default:
      console.log('unknown:', o)
      break;
  }
}

f({kind: 'circle', radius: 2.0});
f({kind: 'rectangle', height: 2.0, width: 3.0});
f({kind: 'hexagon', side: 2.0});

Who does this impact? Who is this for?

Anyone who has a frontend that communicates with a backend, where the frontend and backend might suffer from version skew.

Describe alternatives you've considered

Give up on type checking via parsing and just manually check the discriminator field and then unsafe case the object.

Additional context

Related: microsoft/TypeScript#26277

Your environment

Software Version(s)
io-ts 2.2.19
fp-ts 2.12.3
TypeScript 4.8.4

This is a non-working solution (OK at runtime but with type errors) but it points in a possible direction:

I tried your code and it works like a charm. No type errors. I do not understand what the problem is?

tibbe commented

I tried your code and it works like a charm. No type errors. I do not understand what the problem is?

If you look at the type of o inside one of the case branches it does not have the expected type. For example, o.radius has type unknown, while we'd like it to have type number.

I think what happens is that the type checker fives o.radius type number | unknown which I guess is unknown.

If you change

console.log('circle:', o.radius);

to

console.log('circle:', o.radius + 1);

you will see a type error.

Ok I see an error now. The problem in this case is not io-ts it is typescript as it collapse all kind types to just string so when evaluating kind in your switch it just sees string and has no way of narrowing down the types.

You can solve this only by decoding in two steps. First strictly with your circle and rectangle union and afterwards by checking for a certain structure you expect like t.type({ kind: t.string }).