Home > OS >  Creating an exclusive union from a mapped type
Creating an exclusive union from a mapped type

Time:11-18

I have a strongly typed map that holds the types of possible arguments to a function. I can create the argument types for said function by using mapped types to create a union of the types of the original map's values. The problem I have is trying to find a good way to make those arguments exclusive, i.e., so that a consumer can't "mix and match" fields from the different values of the type. This will make more sense with a code example.

Here's the strongly-typed map (NB: in actual usage these maps and their types are programmatically generated, which limits what I can do to them, i.e., I can't go in an add discriminants or manually change their typing):

type MyMap = {
  foo: Foo
  bar: Bar
  baz: Baz
}

type Foo = {
  x: number
  y: number
}

type Bar = {
  a: number
}

type Baz = {
  l: number,
  j: number,
  k: number
}

// A type-erased version of it to use for constraints
type SomeMap = Record<string|number|symbol,Record<string,any>

And here's an example of a function that will use the types of the map's properties as possible arguments:

type Args<T extends SomeMap> = {
  [K in keyof T]: T[K]
}[keyof T]

function foo<T extends SomeMap>(args:Args<T>){
    //do something
}

Now, this doesn't work, and the reason is because TS unions are not exclusive. So while this will fail (because z isn't in any of the sub-types):

foo<MyMap>({z: 5})

this won't (because it mixes and matches parameters from Foo and Bar):

foo<MyMap>({x:1, y:2, a:1})

My question is what's the best way to "fix" this, i.e., to only allow arguments that match the object literals of Foo, Bar, and Baz?

I've read the TS comments on the XOR proposal, and I think I get the idea behind a solution, namely, some conditional type that forces the non-selected type properties to undefined, but I can't seem to get it to work here.

I tried to define an ExclusiveArgs type as follows:

type ExclusiveArgs<T extends SomeMap> = {
  [K in keyof T]: T[K] & NotAnyOthers<T,K>
}[keyof T] 

type NotAnyOthers<T extends SomeMap, K extends keyof SomeMap> = {
  [Z in keyof Omit<T,K>]: MakeUndefined<Omit<T,K>[Z]>
}[keyof Omit<T,K>]

type MakeUndefined<T extends Record<any,any>> = {
  [K in keyof T]: undefined
}


function bar<T extends SomeMap>(args:ExclusiveArgs<T>){
    //do something
}

This doesn't work, i.e., now even "correct" examples fail unless I spell out the values, including undefined for every possible parameter. I'm guessing this is because TS otherwise doesn't know which type I'm "aiming" for.

Any ideas on how to make my solution work or a better/more elegant solution? I realize it would probably be easier if Foo/Bar/Baz had discriminant fields in them, but I don't have any way to add those in this environment. That said, it is guaranteed that they will not have overlapping properties (i.e., Foo and Baz won't have the same field x)

Working examples of the problem, my attempt to solve it, and how it fails are in this playground.

CodePudding user response:

This is possible by adapting your example a little bit. The key peice is instead of forcing the additional properties to be undefined, allow them to be optional never. This will make it so that those additional keys must be omitted in the passed object. For example:

type UnionToIntersection<U> = (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never;

// Here is the magic type, it will allow objects of type Base as long as they contain no properties from any of the "Extras".
type RefuseExtras<Base extends Record<string, any>, Extras extends Record<string, any>> = Base & {
        [K in keyof UnionToIntersection<Extras>]?: never;
}

// The use of Omit here gives the RefuseExtras type one part of the union as the Base, and the remaining parts as Extras.
type ExclusiveArgs<OptionsMap extends Record<string, Record<string, any>>> = {
        [K in keyof OptionsMap]: RefuseExtras<OptionsMap[K], Omit<OptionsMap, K>[keyof Omit<OptionsMap, K>]>
}[keyof OptionsMap]

Then, you will get errors when specifying additional properties on any of those types, but they work when defined exactly:

function bar<T extends MyMap>(args: ExclusiveArgs<T>){
    //do something
}

// All works!
bar({a: 2})
bar({x: 10, y: 20})
bar({l: 1, j: 2, k: 3})

// But then:
bar({a: 10, x: 10}) // Type '{ a: number; x: number; }' is missing the following properties from type 'Baz': l, j, k
bar({x: 10, y: 10, f: 1}) // Object literal may only specify known properties, and 'f' does not exist in type 'RefuseExtras<Foo, Bar | Baz>'

However you can still specify additional properties not present in the other parts of the union if you don't use an object literal. The restriction only applies to properties on other members of the union:

const withF = {x: 10, y: 10, f: 1};
bar(withF) // Works

const withA = {x: 10, y: 10, a: 1};
bar(withA) // Still errors: Type '{ x: number; y: number; a: number; }' is missing the following properties from type 'Baz': l, j, k

Playground Link

  • Related