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

Union of unions #13

Closed
matomesc opened this issue Mar 22, 2020 · 12 comments · Fixed by #36
Closed

Union of unions #13

matomesc opened this issue Mar 22, 2020 · 12 comments · Fixed by #36

Comments

@matomesc
Copy link
Contributor

matomesc commented Mar 22, 2020

Hi, it it possible to create a union of unions?

I tried doing the following

Type.Union([
  Type.Union([ ... ]),
  Type.Union([ ... ])
]);

but the static type is any.

My use case is a large union that I would like to break up into smaller unions. I suppose that increase the union size from 8 can also work but I'd rather compose the smaller unions.

@sinclairzx81
Copy link
Owner

Hi.

Unfortunately, I don't believe this to be possible inside TypeBox without creating a circular type reference when conditionally mapping, which TypeScript currently forbids.

Note that the types TUnion, TTuple and TIntersect are all of type TComposite. You can nest TComposite to produce valid schemas (i.e nesting Unions), however the inference rules in TypeBox only allow resolving the top most Composite, not nested Composites due to the TypeScript rules around recursive types.

To work around this, you infer explicitly, as follows.

const X = Type.Union([ Type.Number()  ])
const Y = Type.Union([ Type.String()  ])
const Z = Type.Union([ Type.Boolean() ])
const W = Type.Union([X, Y, Z])

// ... instead of

type W = Static<typeof W> // -> any 

// ... try

type W = Static<typeof X | typeof Y | typeof Z> // -> number | string | boolean

Hope that helps
S

@matomesc
Copy link
Contributor Author

Perfect this works. Thanks for the quick help ❤

@geekflyer
Copy link
Contributor

geekflyer commented May 6, 2020

heyho, I just bumped into the same problem. I was wondering if there's a more elegant solution to this? In io-ts this works:

import * as t from 'io-ts';

const unionA = t.union([t.number, t.string]);
const unionB = t.union([t.boolean, t.number]);
const unionC = t.union([unionA, unionB]);

type UnionC = t.TypeOf<typeof unionC> // -> string | number | boolean

Either way I think if typebox cannot support this at all I think we should put a hint about this limitation into the docs. I first thought something's wrong with my business model and it took me a quite a while to realize that nesting unions or intersections simply doesn't work in typebox :)

@sinclairzx81
Copy link
Owner

@geekflyer Heya, I haven't spent a lot of time looking at how io-ts does its thing to be honest, but if they have figured out a way of working through the recursive problem outlined above, id certainly be interested in exploring their approach.

Will reopen this issue for further discussion. PR's also welcome :)

@sinclairzx81 sinclairzx81 reopened this May 6, 2020
@matomesc
Copy link
Contributor Author

matomesc commented Jun 9, 2020

I've generated types for larger unions of size 20: https://gist.github.com/matomesc/d512b21288c21d0625b8982db38fc117

@geekflyer
Copy link
Contributor

geekflyer commented Jun 9, 2020

in my project we're using meanwhile a custom combinator called mergeObjectSchemas that can merge/intersect multiple object schemas recursively and still infer the types correctly - it's sort of a replacement for Type.Intersection.
I don't fully understand how the typescript inference magic here works, but here's a snippet:

import { mergeAll } from "remeda";

/*
  Here we want to intersect the types of `properties` prop as the result of merge, so that
  when converted to static types it includes all the fields.

  Since we pass in an array, type of our T["properties"] is an union of `properties` such as:
    { x: TString} | { y: TNumber } | { z: TBoolean }
  
  First we convert this union to an intersection using the property defined as this in TS handbook:
    "[...] multiple candidates for the same type variable in contra-variant positions
    causes an intersection type to be inferred"

  Then we have to tell the compiler that our intersection actually extends TProperties, which we do
  by using a `Cast` utility type
*/
type UnionToIntersection<U> = (U extends TProperties ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type Cast<T, K> = T extends K ? T : K;

export function mergeObjectSchemas<T extends TObject<TProperties> | TMap<UserDefinedOptions>>(
  schemas: T[],
  options?: UserDefinedOptions
): TObject<Cast<UnionToIntersection<T["properties"]>, TProperties>> {
  return {
    ...options,
    type: "object",
    properties: mergeAll(schemas.map(schema => schema.properties)) as T["properties"],
    required: schemas.flatMap(schema => schema.required ?? [])
  };
}

this can be used in the same way as Type.Intersection but supports Intersections of Intersections of Intersections... and so on.
That particular implementation also merges the json-schemas, instead of producing a allOf json-schema which we've found to produce ugly validation errors/api docs.
The merging of the json-schemas isn't perfect in some edge-cases, but it worked great so far for our use-cases.

@matomesc
Copy link
Contributor Author

Nice solution and I think this approach can be used for merging unions as well. Also I noticed that the required array contains duplicate keys, eg. merging two objects that have the same required key.

@matomesc
Copy link
Contributor Author

matomesc commented Jun 21, 2020

@sinclairzx81 adding the patch for large unions slows the compiler down significantly (5x in my case for 77 unions in a small 2k LOC project). I'm not sure why this is happening - I'm not familiar with debugging the compiler. Perhaps the TypeScript team can shed some light into why the union types kill performance.

Edit: it's both unions and tuples and the larger they are the slower the compiler.

@geekflyer
Copy link
Contributor

It'd say that's not entirely unexpected. The thing is with that large patch TypeScript has a zillion candidate type combinations to tests for, with many inferred type parameters.
Type inference isn't cheap, that's probably one reason why Scala's compiler is so slow :-D.
Just for reference, io-ts has some performance tip for large string unions: https://github.com/gcanti/io-ts/blob/master/index.md#tips-and-tricks

@sinclairzx81
Copy link
Owner

Some options may exist in TS 4.0 to get rid of those composite variants. For review.

microsoft/TypeScript#39094
microsoft/TypeScript#5453

@matomesc
Copy link
Contributor Author

matomesc commented Jul 2, 2020

Performance issues, including large unions, are fixed in TS 4.0.0-beta 🎉

@sinclairzx81
Copy link
Owner

Many thanks to @mooyoul for the excellent PR. @matomesc Have given these updates a quick smoke test, and unions of unions should work as expected. Will be ticking a major revision to 4.0.x in which to align to TS 4.0.

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

Successfully merging a pull request may close this issue.

3 participants