brianeyster

Brian Eyster

Posted on January 6, 2022

The Value of a Pick<>

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

🎵 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;
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>>;
Enter fullscreen mode Exit fullscreen mode

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'.
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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'.
});
Enter fullscreen mode Exit fullscreen mode

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!
});
Enter fullscreen mode Exit fullscreen mode

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 is PunchBrother[’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 & stringwould 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];
}
Enter fullscreen mode Exit fullscreen mode

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 single T 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!
});
Enter fullscreen mode Exit fullscreen mode

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, since typeof a nested object just returns object. There’s probably a solution involving recursively checking types of nested properties. But at that point, our 1-line copyField() 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.

💖 💪 🙅 🚩
brianeyster
Brian Eyster

Posted on January 6, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

The Value of a Pick<>
typescript The Value of a Pick<>

January 6, 2022