Reputation: 559
I have the following scenario.
function foo<A extends object, B extends A>(
a: A,
b: Pick<B, Exclude<keyof B, keyof A>>
): B {
return undefined as any;
}
const r = foo<{ a: string }, { a: string; b: number }>({ a: "" }, { b: 2 });
I would like for b
to be an object that only has keys that are not in a
. But I also want to get the type of the resulting object when merging them together.
Upvotes: 2
Views: 6015
Reputation: 327859
I think @DavidSherret has answered the question as you asked it, but I'd be wary of using any generic function where the type parameters can't be inferred from the arguments. In your case, TypeScript can't really infer B
from the b
parameter, and thus you are required to explicitly specify the type parameters yourself when you call foo()
. If that is what you actually want, great. Otherwise, read on:
The easiest way to make TypeScript infer type parameters A
and B
is to make the input arguments a
and b
of type A
and B
respectively. For example:
declare function foo<A, B>(a: A, b: B): A & B;
This will behave as expected when you do this:
const r = foo({ a: "" }, { b: 2 });
// A is inferred as {a: string}
// B is inferred as {b: number}
// output is therefore {a: string} & {b: number}
The intersection-typed output is equivalent to {a: string, b: number}
. If you really need it to be exactly {a: string, b: number}
instead of equivalent, you can use a mapped type to do so:
type Id<T> = { [K in keyof T]: T[K] };
declare function foo<A, B>(a: A, b: B): Id<A & B>;
const r = foo({ a: "" }, { b: 2 });
// A is inferred as {a: string}
// B is inferred as {b: number}
// output is {a: string, b: number}
Now you're probably complaining that this doesn't prevent you from b
having overlapping keys with a
, which was the whole point of your question.
const bad = foo({ a: "" }, { a: 3 }); // not an error!!
So let's fix that:
function foo<A, B extends { [K in keyof B]: K extends keyof A ? never : B[K] }>(
a: A, b: B
): Id<A & B> {
return Object.assign({}, a, b); // implementation, why not
}
Now we've constrained B
to be { [K in keyof B]: K extends keyof A ? never : B[K]}
, which is... something. Let's pick it apart. It has the same keys as B
([K in keyof B]
), and for each key, if the key is part of keyof A
, the value is never
. Otherwise (if the key is not part of keyof A
), the value is the same as in B
. So it's basically saying that B
must be constrained to a type where any keys overlapping with A
must have a type of never
. For example, if A
is {a: string}
, and B
is {a: number, b: boolean}
, then that mapped type becomes {a: never, b: boolean}
. And since {a: number, b: boolean}
does not extend {a: never, b: boolean}
, this would fail. Maybe that's too much explanation. Let's see it in action:
const r = foo({ a: "" }, { b: 2 }); // {a: string, b: number}
This still works as desired. But the following fails with an error
const bad = foo({ a: "" }, { a: 3 }); // error in second argument
// types of property 'a' are incompatible.
// 'number' is not assignable to type 'never'
Which is what you want. So you get the same behavior as your intended function, with type parameter inference as well! Okay, hope that helps. Good luck.
Upvotes: 2
Reputation: 106640
This is possible by using Exclude
on the keys and then creating the final object type using Pick
on those filtered keys:
function foo<A extends object, B extends A>(
a: A,
b: Pick<B, Exclude<keyof B, keyof A>>,
): B {
return undefined as any;
}
Just to explain this out a bit more:
type A = { a: string };
type B = { a: string; b: number };
// "b"
type FilteredProps = Exclude<keyof B, keyof A>;
// { b: number; }
type FinalType = Pick<B, FilteredProps>;
The reason why Exclude<B, Pick<B, keyof A>>
was not working was because:
// { a: string; }
type PickedObject = Pick<B, keyof A>;
// never, because { a: string; b: number; } extends { a: string; }
type FinalType = Exclude<B, PickedObject>;
// for reference...
type Exclude<T, U> = T extends U ? never : T;
Upvotes: 5