Typescript lookup types - correctly narrow down properties set when merging with Partial

850 Views Asked by At

I'm playing with lookup types and would like to build kind of safe-merge util function (one that takes entity of type T and an object containing subset of keys of T to update). My goal is to let compiler tell me when I misspell a property or try to append non-existing one for T.

So I have Person and use built-in Partial in (v2.1) like this:

interface Person {
  name: string
  age: number
  active: boolean
}

function mergeAsNew<T>(a: T, b: Partial<T>): T {
  return Object.assign({}, a, b);
}

Now I apply this to the following data:

let p: Person = {
  name: 'john',
  age: 33,
  active: false
};

let newPropsOk = {
  name: 'john doe',
  active: true
};

let newPropsErr = {
  fullname: 'john doe',
  enabled: true
};

mergeAsNew(p, newPropsOk);
mergeAsNew(p, newPropsErr); // <----- I want tsc to yell at me here because of trying to assign non-existing props

I would like TS compiler to yell at me on the second invocation as fullname and enabled aren't props of Person. Unfortunately this compiles fine locally, but... when I do the same in online TS Playground I get more or less what I expect:

The type argument for type parameter 'T' cannot be inferred from the usage. Consider specifying the type arguments explicitly.
  Type argument candidate 'Person' is not a valid type argument because it is not a supertype of candidate '{ fullname: string; enabled: boolean; }'.
    Property 'name' is missing in type '{ fullname: string; enabled: boolean; }'.

Looks like the playground uses the same version as I do locally (2.1.4). Does anybody have a clue why these two may differ?

Bonus question:

when I try the following assignment:

let x: Person = mergeAsNew(p, newPropsOk);

I get the following error on x but only on the playground (it's all fine locally):

Type '{ name: string; active: boolean; }' is not assignable to type 'Person'.
  Property 'age' is missing in type '{ name: string; active: boolean; }'.

Why is that? Shouldn't it be of Person type, as first mergeAsNew argument is Person and and everything else is Person-props subset (so it's at most Person)?

EDIT Here is my tsconfig.json:

{
  "compilerOptions": {
    "target": "ES6",
    "module": "commonjs",
    "noEmitOnError": true,
    "allowJs": false,
    "sourceMap": true,
    "strictNullChecks": true
  },
  "exclude": ["dist", "scripts"]
}
2

There are 2 best solutions below

3
On

When you want to express that one type has only a subset of properties from another type, plain old extends can help too

interface Person {
    name: string
    age: number
    active: boolean
}


let p: Person = {
    name: 'john',
    age: 33,
    active: false
};

let newPropsOk = {
    name: 'john doe',
    active: true
};

let newPropsErr = {
  fullname: 'john doe',
  enabled: true
};

// a extends b and not the other way round
//because we don't want to allow b to have properties not in a
function mergeAsNew<T2, T1 extends T2>(a: T1, b: T2): T1 {
    return Object.assign({}, a, b);
}

mergeAsNew(p, newPropsOk); // ok
mergeAsNew(p, newPropsErr); 
// Argument of type 'Person' is not assignable 
// to parameter of type '{ fullname: string; enabled: boolean; }'.
//   Property 'fullname' is missing in type 'Person'.

PS no idea what's going on with playground and mapped types

0
On

Ok, looks like I was too fixed on that yesterday. I got some sleep and it looks like it's (probably) impossible to do what I want that way (using Partial). Leaving this weird playground behaviour aside for a while, here is why (at least I think so):

To recap:

interface Person {
  name: string
  age: number
  active: boolean
}

function mergeAsNew<T>(a: T, b: Partial<T>): T {
  return Object.assign({}, a, b);
}

In this code, Partial<Person> may (but doesn't have to) contain any of Person props, so the following is perfectly valid Partial<Person> type:

let newPropsErr = {
  fullname: 'john doe',
  enabled: true
};

As Typescript has structural typing it doesn't care about extra props, it checks only shape of the object and here shape fits Partial<Person> just fine.

The only way to somehow check argument's shape agains Partial<Person> for extra props is to pass it as object literal (literals are strictly checked to match type)

Now to mergeAsNew function itself. I do Object.assign here which does exactly what it's designed for - merges props from both args into new object (all of them). Because types are only for TS compilation, there is no way in runtime to constraint and selectively pick props from b and apply them to override a props.

Looks like @Artem's answer that does what I need with extend and no mapped/lookup types.

Anyway, I still don't get why TS Playground works the way it works (what initially give me an impression that all that stuff is perfectly doable).