Recently I was writing an API handler that retrieves an object and then returns a partial copy with only the object properties “picked” by the caller. Pretty standard stuff... until TypeScript intervened.
In Typescript, we have the generic utility type Pick<T, K>
. It’s super handy. Pick<T, K>
returns a type with only some of the properties (described by the string union K
) of the original object’s type (T
). Since Pick
is a Typescript utility type, it only acts on the types (not the values of the object). So all Pick’s hard work gets effectively erased at runtime and doesn’t alter the actual object being returned. 😔
How do we code this same Pick-like functionality in the world of runtime values, while still preserving the type safety of TypeScript? My investigation into this seemingly simple question, led me to several interesting discoveries and surprises about TypeScript.
Our musical example
To illustrate my example, let’s call on one of the most inspiring bands in progressive acoustic music :
type PunchBrother = {
name: string;
instrument: string;
leadSinger: boolean;
};
const mandolinist = {
name: 'Chris Thile', // virtuoso mandolinist
instrument: 'mandolin',
leadSinger: true,
};
Our aim is to write a function that returns just a few properties of the mandolinist
object:
function punchBrotherPick(musician: PunchBrother, keys: Array<keyof PunchBrother>): Partial<PunchBrother> {
// ... ??? ...
return partialBrother;
}
Note that we define the return type using Typescript’s Partial<T>
utility type since we may only be selecting some of the properties of the object (and thus omitting others).
We'll then call our function like:
const mandolinistName = punchBrotherPick(mandolinist, ['name']);
mandolinistName.name === 'Chris Thile'; // true
mandolinistName.instrument === undefined; // true, type is Partial<PunchBrother>
mandolinistName.faveCocktail; // type error, 'faveCocktail' does not exist on Partial<PunchBrother>
🎵 My, oh my. What a wonderful day we’re having… 🎵
Destructuring a dynamic list of properties
Quick searches on StackOverflow all suggest the elegant approach of object destructuring with rest parameters:
const { key1, key2, ...withoutKey1Key2 } = origObj;
Ah, yes. I love that destructuring syntax for its simple clarity. withoutKey1Key2
now contains all properties in origObj
minus key1
and key2
.
Note that this one-liner more closely mimics Typescript’s Omit<T, K>
since withoutKey1Key2
now omits key1
and key2
. But we can quickly spread the key1
and key2
properties back into a new object to get the functionality similar to Pick.
const { key1, key2, ...rest } = origObj;
const onlyKey1Key2 = { key1, key2 };
Unfortunately, this approach won’t work here. Destructuring only works when the number of extracted properties is static and known at compile time. In our more general case of picking an arbitrary, dynamic array of properties (specified by the caller as an array of keys), destructuring isn’t possible (See this SO article) .
A couple asides:
- Note that you can destructure with a dynamic key name via
{ [keyNameVar]: var, …rest}
. Very hip! - The problem here is specifying an arbitrary quantity of these dynamic keys. You’d need a meta-programming way of specifying the destructure syntax. If that’s possible in Javascript I’d love to hear about it!
Clone then mutate
Another option is to first clone the object (using your clone method of choice), then selectively remove the properties we don’t need via Javascript’s delete
.
const partialThile: Partial<PunchBrother> = Object.assign({}, mandolinist); // cloned object
delete partialThile.instrument;
delete partialThile.leadSinger;
It’s nice to know that delete
is sound with regards to types. In order for a property to be deleted, Typescript requires that the property must already be optional on the object. Well done, TS!
But I’m not thrilled with this approach, as it is more analogous in spirit to Typescript’s Omit
. We have to clone the entire object, then remove the fields that we don’t want to include. This approaches the idea of Pick
from its inverse.
Interestingly, Omit
itself is defined in TS (/lib/es5.d.ts) using Pick and Exclude:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
But let’s dig deeper into this approach as there are some other potential problems.
Iterating over keys of an object
At runtime, all properties of an object are visible, even ones that have been “hidden” from TypeScript via type narrowing. We might iterate over the keys of a PunchBrother object expecting to see just our 3 PunchBrother properties, but actually see additional properties. Consider this:
// Punch Brothers bassist
const paulKowert = {
name: 'Paul Kowert',
instrument: 'bass',
leadSinger: false,
otherBands: ['Hawktail'] // field not declared on PunchBrothers type
}
const punchPaul: PunchBrother = paulKowert; // type narrowing
punchPaul.otherBands; // Type Error: Property 'otherBands' does not exist on type 'PunchBrother'.
As expected, TypeScript errors if we attempt to access punchPaul.otherBands
. But at runtime, if we attempt to iterate over the keys of punchPaul
, we will see the otherBands
property as well as the 3 PunchBrother properties. Type narrowing like this only happens at compile time; these types are completely erased from the runtime Javascript.
The TypeScript designers made the decision to type the return value of Object.keys
and for..in
as string
rather than keyof obj
for this reason: the compiler just can’t be certain there aren’t other properties on the object. (See lots of great info and links on this StackOverflow post).
We can get some type safety by using the for…in
syntax. If we declare the key variable inside the for..in
the key will be of type string. But we can declare our key
variable prior to the for..in
and include a type annotation:
let key: keyof PunchBrother;
for (let key in punchPaul) { ... } // type of key is still `keyof PunchBrother`
Curiously (?), we can annotate our type with a narrower type here (keyof PunchBrother
is narrower than string
) and not receive a TypeScript error when using the variable in the for..in
.
This satisfies the TypeScript compiler, but it is not sound. In our punchPaul
example, the runtime value of key
can still be otherBands
which is not a member of the union keyof PunchBrother
.
The use of for..in
this way is fine if we know that our object exactly matches the type and doesn’t possesses any properties beyond those declared in the type. But if our object is narrowed from another type, as in the case above, the type declaration for key
may not be sound.
Given the potential unsoundness of iterating over object keys, as well as the semantic backwardness of a “clone then mutate” approach, let’s look at a better solution.
Selectively copy properties
The more natural approach to our initial issue is to begin with an empty object ({}
) and selectively copy the requested properties from the source object. (This is the approach used by the Just utility library’s just-pick.)
Here’s the naive code:
const thileInstrument: Partial<PunchBrother> = {}; // must be Partial
const fields: Array<keyof PunchBrother> = ['instrument'];
fields.forEach((key) => {
thileInstrument[key] = thile[key]; // Error: Type 'string | boolean' is not assignable to type 'undefined'.
});
And now we reach the most surprising hurdle of this article: copying fields between 2 objects. Our innocent little code: target[key] = src[key]
yields a type error: Type 'string | boolean' is not assignable to type 'undefined'.
Huh? Isn’t it self-evident that this is type-safe? The objects are the same type, we’re using the same keys, shouldn’t all the types match? And equally surprising, why is the type of the left-hand-side (target[key]) 'undefined'?
Let’s break this down from the perspective of the TypeScript compiler. For each iteration of the loop, there is a single key. But at compile time, Typescript doesn’t know which key. So it also can’t know the type of the property in the object: srcObj[key]
.
For clarity, let’s introduce a temporary variable for the right-hand side (RHS) value:
fields.forEach((key) => {
const rhs = thile[key]; // inferred type is: 'string | boolean'
thileInstrument[key] = rhs; // Error!
});
Type of the RHS
The type of the right-hand side in the assignment is the union of all possible property types in the object.
To quickly unpack this indexed access type:
- The type of
key
is’name’ | ‘instrument’ | ‘singer’
. - So the type of
rhs
isPunchBrother[’name’ | ‘numInstruments’ | ‘singer’]
- After distributing out the string union:
PunchBrothers[‘name’] | PunchBrothers[‘instrument’] | PunchBrothers[‘singer’]
- This simplifies to:
string | boolean
Type of the LHS
While the type of the RHS feels immediately intuitive (the union of all property types), the type of the left-hand side of the assignment is somewhat surprising.
TypeScript resolves the type of a left-hand side of an assignment to be the intersection 🤯 of the types of all properties on the object. (Let that sink in for a minute...) This is a deliberate (though unfamiliar to me!) decision by the TypeScript designers to make assignments as sound as possible. For more details see this TypeScript PR discussion and this excellent post about “unexpected intersections”).
🎵 It’s all part of the plan 🎵.
The basic intuition is that the type of LHS should resolve to the set of types that can safely be assigned to. This type set is represented by the intersection of all the property types. When the intersection of property types is a single concrete type, the type-safety of this assignment is clear. For example, if the object type was the simpler: Record<K, string>
then the intersection of string & string & string
would be string
and the assignment above would be type-safe.
But in our case the type of the LHS is: ’string & number & undefined’
(Recall that our LHS is of type Partial<PunchBrother>
so each property may also be undefined
.)
As string
and number
do not overlap, this intersection should resolve to never
. Or in our specific case, where our left-hand side object is a Partial<>
, this may actually resolve to undefined
. Regardless, the types in the LHS and RHS aren’t compatible.
(🎵 I'm a magnet , And you're a magnet, And we're pushing each other away. 🎵)
A TypeScript assignment solution
Given the type incompatibility between the LHS and RHS of the assignment, we need a different approach. The problem is that TypeScript only knows the type of either side as T[K]
, where K
is the set of all keys. So intuitively, the solution is to explicitly freeze (technically called “bind”) the specific key for the LHS and RHS on each iteration of the loop. Let’s call a generic helper function for each different key value:
function copyField<T>(target: T, src: Readonly<T>, key: keyof T): void {
target[key] = src[key];
}
TypeScript is perfectly happy with this assignment. It now knows the objects are the same type, the key is a property on their type, and we’re accessing the same property in both objects.
In order for TypeScript to fully resolve the specific LHS and RHS type, you might think we’d need to also make the key type explicit like so:
function copyField<T, K extends keyof T>(target: T, src: Readonly<T>, key: K)
. But simply introducing the singleT
generic parameter is sufficient for TypeScript to infer the specific type for the LHS and RHS. Thus, we are able to avoid introducing a second generic parameter which would otherwise violate The Golden Rule of Generics .
🎵 Heaven’s a julep on the porch 🎵!
Adding this utility function to the loop, here’s our full type-safe solution.
const thileInstrument: Partial<PunchBrother> = {};
const fields: Array<keyof PunchBrother> = ['instrument'];
function copyField<T>(target: T, src: Readonly<T>, key: keyof T): void {
target[key] = src[key];
}
fields.forEach((key) => {
copyField(thileInstrument, thile, key); // TypeScript success!
});
Depending on the situation, it might make sense to inline this 1-line copyField()
function as a quick TypeScript IIFE. But that risks further obfuscating the solution to our seemingly very simple situation.
One more aside: My initial TypeScript intuition was to search for a runtime / type-guard solution, along the lines of
if typeof target[key] === typeof src[key]
. That’s often the solution to runtime type issues. But alas that approach doesn’t generalize for the common case of nested objects, sincetypeof
a nested object just returnsobject
. There’s probably a solution involving recursively checking types of nested properties. But at that point, our 1-linecopyField()
utility function is looking pretty nice!
Ok, but is this worth it?
In general, the aim of TypeScript is to provide safety and confidence in parts of our code where we might realistically make a mistake and introduce a bug.
Part of TypeScript’s allure lies in the fact that programmers are rarely good at knowing where they’re “realistically” likely to make a mistake — or where future maintainers might introduce a compounding mistake. In complicated code with function calls spanning many files, this compile-time static validation is invaluable. But is a simple copying of values between 2 objects of the same type one of those areas?
Couldn’t we have just asserted type any
on the right-hand side of the assignment and been done awhile ago? (or suppress the error via // @ts-ignore
) ?
Isn’t the added complexity (over-engineering?!) of this code more likely to introduce future confusion than the added type safety of the original assignment? We’re introducing an additional function (or IIFE) with a TypeScript generic, and we’re ( 😱 eek! 😱) mutating one of our function arguments. Is it worth all that additional complexity?
It’s up to you and your team. But this utility function does provide the additional confidence that:
- both the source and the target object are the same type,
- the key is valid on the objects,
- we’re copying the same key (and thus the same type) on both sides of the assignment operator.
Ultimately, I think this falls into the gray area of a static tool like TypeScript. If your code is self-evident and isolated, then the additional cognitive overhead might not be necessary. But used with complex objects that might be subtypes, I can see a value in this little one-liner.
What do you think? Was this a worthwhile use of TypeScript generics? I'd love to hear your thoughts in the comments below.
Top comments (0)