1

I have a schema defined

type Schema = {
    a: { a: 1 }
    b: { b: 2 }
}

And I want a function to create objects that conform to multiple schemas.

function createObject<K extends keyof Schema>(schema: Array<K>, obj: Schema[K]) {}

createObject(["a"], { a: 1 }) // works
createObject(["b"], { b: 2 }) // works
createObject(["a", "b"], { b: 2 }) // doesn't error but it should
createObject(["a", "b"], { a: 1, b: 2 }) // works

playground link

I've tried a few other things. Interestingly when you & a union with itself, it does a distributes the & across all items in the union and doesn't quite get me what I want. I want some to operate on {a: 1} | {b: 2} to get {a: 1, b: 2}. Any ideas?

Chet
  • 18,421
  • 15
  • 69
  • 113

1 Answers1

9

Assuming the types inside your Schema properties are not themselves unions, you can convert the union type Schema[K] to an intersection using conditional types, like this:

type Schema = {
    a: { a: 1 }
    b: { b: 2 }
};    

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

function createObject<K extends keyof Schema>(
    schema: Array<K>, 
    obj: UnionToIntersection<Schema[K]>
) { }

createObject(["a"], { a: 1 }) // works
createObject(["b"], { b: 2 }) // works
createObject(["a", "b"], { b: 2 }) // error! 
createObject(["a", "b"], { a: 1, b: 2 }) // works

This might be enough for you. If you have some other use case (e.g., if Schema has a property like cd: {c: 3} | {d: 4} and you want there to still be a union in the final type) a different solution could be more appropriate:

type PropsToIntersection<T, K extends keyof T> =
    { [P in K]: (k: T[P]) => void }[K] extends
    ((k: infer I) => void) ? I : never;

function createObject<K extends keyof Schema>(
    schema: Array<K>,
    obj: PropsToIntersection<Schema, K>
) { }

That's similar except it walks through the keys of Schema and then performs an intersection, instead of spreading the union of Schema[K]. Again, the difference only shows up in cases where some of your schema properties may themselves be unions.

Okay, hope that helps. Good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Interesting! Can you explain how `infer` works in this context? I've never actually found a use for it before. – Chet May 20 '19 at 17:07
  • [Inference in conditional types](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#type-inference-in-conditional-types) with `infer` lets you pull a type out of a type expression. So `ReturnType(()=>string)` evaluates to `string` because `(() => string)` matches `((...args: any[]) => infer R)` when `string` is inferred for `R`. – jcalz May 20 '19 at 18:08
  • 1
    If you have multiple possible candidates for an inferred type, the compiler can return either a union or an intersection depending on how those candidates are used. `((() => A) | (() => B)) extends (() => infer T) ? T : never` will produce `A | B` because function types are covariant in their return type. But `((x: A) => void) | ((y: B) => void) extends ((z: infer T) => void) ? T : never` will produce `A & B` because function types are [contravariant in their argument types](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#strict-function-types). – jcalz May 20 '19 at 18:13
  • It is this inference of intersections when the inference sites are used in contravariant positions that powers `UnionToIntersection` and `PropsToIntersection`. We just take the types we'd like to intersect, put them in a contravariant position (e.g., function parameter), and do the inference to pull out the intersected type. – jcalz May 20 '19 at 18:15
  • Wow. Thank you for this explanation. This all makes so much more sense now! – Chet May 21 '19 at 16:22