1

I'm trying to implement generic function. JS analog of this function is:

const getFactory = (field) => (value) => ({ [field]: value })

use case:

// types
type Field1 = { field1: string }
type Field2 = { field2: boolean }
type Field3 = { field3: number }
type Field4 = { field4: boolean }
type Type1 = Field1 | Field2 | Field4
type Type2 = Field2 | Field3 | Field4

// use case
const field1Factory        = getFactory<Type1>('field1')
const field2Factory        = getFactory<Type1>('field2')
const field4Factory        = getFactory<Type1>('field4')

const anotherField2Factory = getFactory<Type2>('field2')
const field3Factory        = getFactory<Type2>('field3')
const anotherField4Factory = getFactory<Type2>('field4')

const typeField1        = field1Factory('test')       // { field1: 'test' }
const typeField2        = field2Factory(true)         // { field2: true }
const typeField4        = field4Factory(false)        // { field4: false }
const typeAnotherField2 = anotherField2Factory(false) // { field2: false }
const typeField3        = field3Factory(3)            // { field3: 3 }
const typeAnotherField4 = anotherField4Factory(true)  // { field4: true }

I supposed it maybe looks like this:

const getFactory = <Type extends {}>(field: keyof Type) => (value: Type[keyof Type]) => ({ [field]: value })

but when I trying to get factory (const field1Factory = getFactory('field1')) there is an Error:

Argument of type '"field1"' is not assignable to parameter of type 'never'.(2345)

2 Answers2

3

The problem is that keyof does not distribute over union types. You can verify this by inspecting keyof Type1:

type KeyOfType1 = keyof Type1;
// KeyOfType1 = never

This happens because an object of type (A | B) is only guaranteed to have the keys in common to both A and B. But Type1 is a union of objects with no keys in common, so there are no particular keys a Type1 object is guaranteed to have.

So regardless of the type annotations on getFactory, this can't do what you want. You need to change Type1 and Type2 to intersection types. (If you need the original union types for something else, see this answer for a way to automatically construct the intersection type from the union type.)

type Type1 = Field1 & Field2 & Field4
type Type2 = Field2 & Field3 & Field4

This works because keyof (A & B) is equivalent to (keyof A) | (keyof B), since an A & B object has the all the properties of A and B together in one object.

This fixes the type errors, but the resulting types aren't inferred as specifically as you want, even if we assert { [field]: value } as the most specific type possible in the getFactory definition:

const getFactory =
    <T>(field: keyof T) =>
        (value: T[keyof T]) =>
            ({ [field]: value } as { [K in keyof T]: T[K] })

const field1Factory = getFactory<Type1>('field1')

const typeField1 = field1Factory('test')

/* typeof typeField1 = {
    field1: string;
    field2: boolean;
    field4: boolean;
} */

This is because field1Factory is called with T = Type1, so its properties are keyof Type1 which includes all three field names. To do better, you need another type parameter K extends keyof T for the field name, to infer which specific key of T is being used. You also want the more specific literal type 'test' for the value, so you need a third type parameter V extends T[K] for this.

Unfortunately, when a function has multiple type parameters, you can't specify one and let the compiler infer the others. Either you have to write the string literal twice like getFactory<Type1, 'field1'>('field1'), or you can add another layer of currying:

const getFactory2 =
    <T>() =>
        <K extends keyof T>(field: K) =>
            <V extends T[K]>(value: V) =>
                ({ [field]: value } as { [k in K]: V })

const field1Factory = getFactory2<Type1>()('field1')

const typeField1 = field1Factory('test')

/* typeof typeField1 = {
    field1: 'test';
} */

Note the extra () in getFactory2<Type1>()('field1'). But at least it infers the correct, most specific type without having to write the field name twice.

The type assertion ... as { [k in K]: V } is necessary because { [field]: ... } will be inferred as [x: string] even when field has the more specific type K.

Playground Link

kaya3
  • 47,440
  • 4
  • 68
  • 97
  • 1
    It is actually possible to keep current type annotation for `Type1` but using conditional types to extract field names: `type ExtractKeys = T extends {} ? keyof T : never;` instead of saying `keyof T` – Dmitriy Nov 27 '19 at 16:15
  • You help me a lot with such detailed explanation! Thanks! – Mikhail Unenov Nov 27 '19 at 16:17
  • @Dmitriy Good suggestion. However, I just tried replacing `K extends keyof T` with `K extends ExtractKeys`, and I can't get it to infer `V` as anything other than `unknown` in the usage examples. The result is that the inferred types at the bottom end up as `string` and `boolean` instead of the more specific `'test'` and `true` literal types. If you can get it to work, I suggest writing it as an answer to the question. I think the intersection type is needed for the mapping `T[K]` even if you can get `K` without it. – kaya3 Nov 27 '19 at 16:29
1

@kaya3 provided a great answer and explanation of the topics involved, however you can keep using union types instead of an intersection, by using conditional types do distribute over a union for extracting keys (ExtractKeys) and another conditional type (ExtractValue) to defer the resolution of the value type to call time enable to capture literal (e.g. true instead of boolean). If you don't need that, you can just use T[K] instead.

type ExtractKeys<T> = T extends {} ? keyof T : never
type ExtractValue<T, K extends string | number | symbol> =
    T extends { [key in K]: infer U } ? U : never

const getFactory =
    <T>() =>
        <K extends ExtractKeys<T>>(field: K) =>
            <V extends ExtractValue<T, K>>(value: V) =>
                ({ [field]: value } as {[k in K]: V})
Dmitriy
  • 2,742
  • 1
  • 17
  • 15