Home > other >  How to write a generic object assign|merge function in typescript?
How to write a generic object assign|merge function in typescript?

Time:10-18

How would I write the equivalent of {...a,...b} as a generic function in typescript 4.4 . I know both a and b are records, but don't know what they are in advance. I'd like to create a generic type that constrains arbitrary functions to the {...a,...b} operation.

Example that isn't working:

type Rec = Record<string,unknown>
type ExtendRec = <X extends Rec, Y extends X>(x: X) => Y
// or <X extends Rec, Y extends Rec>(x: X) => Y&X
const addA:ExtendRec = <X extends Rec>(x:X) => ({...x, a: 'a'})
const addB:ExtendRec = <X extends Rec>(x:X) => ({...x, b: 'b'})
const addC:ExtendRec = <X extends Rec>(x:X) => ({...x, c: 'c'})
const blank = {} // expected {}
const a = addA(blank) // expected {a:string}, actual:{}
const ab = addB(a) // expected {a:string,b:string}, actual:{}
const abc = addC(ab) // expected {a:string,b:string,c:string}, actual:{}

The type error on each of the addA, addB,addC functions is:

Type '<X extends Rec>(x: X) => X & { c: string; }' is not assignable to type 'ExtendRec'.
  Type 'X & { c: string; }' is not assignable to type 'Y'.
    'X & { c: string; }' is assignable to the constraint of type 'Y', but 'Y' could be instantiated with a different subtype of constraint 'Record<string, unknown>'.

The mystifying thing is that it works if I just remove ExtendRec as function annotations, so typescript already has the ability to infer object assign operations correctly, but I can't write a generic function type that constrains an arbitrary function to that extend operation.

CodePudding user response:

I think this type needs a generic (to capture the properties that will be added), as well as the function (to capture the object being extended).

For example:

type ExtendRec<R extends Record<string, unknown>> = <X>(x: X) => X & R

Now the rest works as you expect:

type ExtendRec<R extends Record<string, unknown>> = <X>(x: X) => X & R

const addA: ExtendRec<{ a: string }> = (x) => ({ ...x, a: 'a'})
const addB: ExtendRec<{ b: string }> = (x) => ({ ...x, b: 'b'})
const addC: ExtendRec<{ c: string }> = (x) => ({ ...x, c: 'c'})

const blank = {} // expected {}
const a = addA(blank) // expected {a:string}
const ab = addB(a) // expected {a:string,b:string}
const abc = addC(ab) // expected {a:string,b:string,c:string}

Playground


Or maybe this approach that uses a merge function with generics for both arguments. addA now just uses that where one argument is the generic and the other is a known type.

type MergeFn = <A extends object, B extends object>(a: A, b: B) => A & B
const merge: MergeFn = (a, b) => ({ ...a, ...b })

const addA = <X extends object>(x: X) => merge(x, { a: 'a'})
const addB = <X extends object>(x: X) => merge(x, { b: 'b'})
const addC = <X extends object>(x: X) => merge(x, { c: 'c'})

Playground

This is sort of the same thing as the previous example though. In each case, the added type is declared statically somehow when addA is declared, and X is inferred at the call time of addA.

Note, I'm using object instead of Record<string, unknown> because it's shorter, but that's not really too important here.

  • Related