Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parsing open-ended unions #665

Open
tibbe opened this issue Oct 12, 2022 · 3 comments
Open

Parsing open-ended unions #665

tibbe opened this issue Oct 12, 2022 · 3 comments

Comments

@tibbe
Copy link

tibbe commented Oct 12, 2022

🚀 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
@mlegenhausen
Copy link
Contributor

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
Copy link
Author

tibbe commented Oct 12, 2022

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.

@mlegenhausen
Copy link
Contributor

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 }).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants