How can I set a generic constraint to ensure that a common key between two types also has the same value type?

Viewed 344

I'm creating a function to copy fields from one object to another and I'd like to guarantee:

  • I can only give a key that is common between both objects
  • the given key corresponds to the same value type in both types

How can I implement the second constraint? Here's what I have so far.

function copyField<T1, T2>(input: T1, output: T2, key: keyof T1 & keyof T2) {
  const keyValue = input[key]
  output[key] = keyValue // How can I ensure that the key value is the same for both?

  // ^ Error: Type 'T1[keyof T1 & keyof T2]' is not assignable to type 'T2[keyof T1 & keyof T2]'.
  // Type 'T1' is not assignable to type 'T2'. 'T2' could be instantiated with an
  // arbitrary type which could be unrelated to 'T1'.
}

type A = { field: string }
type B = { field: string }

copyField<A, B>({field: 'hello'}, {field: 'world'}, 'field')

Live example on typescriptlang.org

Update

An answer below got me closer to what I want but still doesn't guarantee that the value for the given key is exactly the same. This demonstrates how mismatched types can still compile because the constraint only guarantees that the type is present somewhere on both objects, not necessarily for the given key.

function copyField<K extends string, V>(input: {[k in K]: V}, output: {[k in K]: V}, key: K) {
  const keyValue = input[key]
  output[key] = keyValue
}

type A = { s: string, mismatch: string }
type B = { s: string, mismatch: number }
//                                 ^--- these don't have the same type

const a: A = {s: 'hello', mismatch: 'hello'}
const b: B = {s: 'world', mismatch: 123}

// This compiles, but I want it to only accept 's'
copyField(a, b, 'mismatch')
3 Answers

Here's a solution:

type CommonKeys<S, T> = {
    [K in keyof S & keyof T]:
        [S[K], T[K]] extends [T[K], S[K]] ? K : never
}[keyof S & keyof T]

It works by first constructing a mapped object type like {s: 's', mismatch: never}, then mapping that to 's' | never which is of course 's'.

Each key K is included if [S[K], T[K]] extends [T[K], S[K]], which checks that S[K] and T[K] are subtypes of each other, i.e. the same type. If this is too strict for what you want, you could change this to S[K] extends T[K] to check only that the input value type is assignable to the output value type.

Playground Link

In case it matters, here's how I'd think of doing it:

function copyField<O, K extends keyof O, I extends Record<K, O[K]>>(
    input: I,
    output: O,
    key: K
) {
    const i: Record<K, O[K]> = input;
    output[key] = i[key];
}

The idea is that you are allowing the type O of output be whatever you want, and allow the type K of key to be any key of O. Then, the type I of input must be constrained to Record<K, O[K]> (see docs for Record), meaning that input must be assignable to something with a property at key K whose value must be assignable to O[K], the type of the same property in O.

Note that I'm not requiring that I[K] and O[K] be identical... it should be sufficient to require that I[K] extends O[K] to allow good copies and disallow bad ones.

Also note: in order for the assignment to be accepted by the compiler inside the function implementation I needed to widen input from I to its constraint Record<K, O[K]>.


This gives the following desirable behavior:

copyField({ good: 0, bad: "", whatever: true }, { good: 4, bad: 1 }, "good"); // okay
copyField({ good: 0, bad: "", whatever: true }, { good: 4, bad: 1 }, "bad"); // error!
// ----------------> ~~~
// Type 'string' is not assignable to type 'number'

Note that the error here appears not on the "bad" value for key, but on the input parameter. In this case, it specifically warns that the bad property is a string, but you can't write a string into a number property.

Similarly, the above definition will allow you to write from a narrow type into a wider type, but not vice versa:

copyField({ x: 123 }, { x: Math.random() < 0.5 ? 456 : "789" }, "x"); // okay
copyField({ x: Math.random() < 0.5 ? 456 : "789" }, { x: 123 }, "x"); // error!
// -------> ~
// Type 'string | number' is not assignable to type 'number'

The first line works because it's safe to copy the number value from input into the wider number | string value from output. But the second line complains because you cannot safely copy a number | string value from input into the narrower number value from output. And the warning explains it.


Playground link to code

Try doing this

function copyField<K extends string, V>(input: {[k in K]: V}, output: {[k in K]: V}, key: K) {
  const keyValue = input[key]
  output[key] = keyValue
}

copyField({field: 'hello'}, {field: 'world'}, 'field')
Related