Home > Blockchain >  Why do Object.values for a Partial Record have undefined typings?
Why do Object.values for a Partial Record have undefined typings?

Time:09-14

When extracting Object.values from a Partial Record, the values are a union between what I would expect and undefined.

const example: Partial<Record<string, number>> = {}
const values = Object.values(example)

// strangely, the typing for values is as follows:
// const values: (number | undefined)[]

Instead of (number | undefined)[], I would expect it to be number[]. Every value in practice is defined, and, unless I'm missing something, always will be.

Code Sandbox showing the issue (with react scaffolding) can be found here

This seems likely to be some artifact of the type system, but I'd like to understand what's happening, and if there's any way to avoid this behavior.

CodePudding user response:

In everything that follows I will assume we are all using the --strict suite of compiler options, in particular the --strictNullChecks compiler option. If you don't have --strictNullChecks enabled then most of the following discussion involving the undefined type won't apply, but you probably wouldn't have the issue raised in this question in the first place.


The Partial<T> utility type is implemented like type Partial<T> = {[K in keyof T]?: T[K]} and applies the optional mapped type modifier to make all of the resulting properties optional.

The Record<K, V> utility type is a mapped type implemented like type Record<K, V> = {[P in K]: V}; and when you do that with string as the key it results in a string index signature like type Dictionary<V> = {[k: string]: V}; where any property whose key is of type string must have a value of type V.


I really wouldn't recommend mixing optional mapped type modifiers (Partial<T>) with index signatures (Record<string, V>). Both of them do, uh, interesting things with the undefined type by default, and there are compiler options to change this default behavior to produce other, uh, interesting behvior which some people prefer and other people do not. And no matter what settings you use, the combination of both of these together is probably going to make you unhappy, at least as of TypeScript 4.8.


First, optional properties: in --strict mode, TypeScript doesn't really distinguish missing properties from ones that are present-but-undefined. It's often reasonable to treat those as the same thing, since reading either give you undefined. But should you be allowed to write undefined to the value? Or should you be able to read undefined after you've used the in operator to check for the presence of the property? This depends on what you think "optional" means. By default, TypeScript says yes, if a property is optional then you should be able to write undefined there. And therefore {x?: string} and {x?: string | undefined} are identical types.

Don't like that? Well, neither did a lot of people... microsoft/TypeScript#13195, "Distinguish missing and undefined", was open for a long time and received hundreds of upvotes. And so with TypeScript 4.4 the --exactOptionalPropertyTypes compiler option was introduced. If you enable that, you will no longer be able to write undefined to an optional property unless undefined is explicitly included in the property type. Hooray!

But it's not part of the --strict suite of compiler options. Maybe if this feature had been introduced with --strictNullChecks in TypeScript 2.0 it would have been. But a lot of real-world code currently expects that optional properties accept undefined, and it would break if this were introduced. And it could be annoying. The simple copying of a property from one object to another of the same type, like a.x = b.x, suddenly isn't acceptable if the property is optional. You need if ("x" in b) {a.x = b.x} else {delete a.x} now. That's an extra hoop to jump through and if you don't really care the missing/undefined distinction then you're not going to be happy about it.

So that's optional properties.


Now for index signatures: in --strict mode, TypeScript doesn't really distinguish missing properties from ones that are present. This really doesn't matter for writing, but for reading it's noticeable. The compiler presumes that if you are reading from a key that matches the index signature, then you know a value is actually there. It doesn't account for the possibility that there is no property value and that you will get undefined. If you wanted to make safer reads you could manually add | undefined to the type yourself, turning {[x: string]: number} into {[x: string]: number | undefined}, but now you are trading the unsafe-read problem for an undesirable-write problem much like the one with optional properties. By default, TypeScript says that if you read from an index signature you're going to get a defined value (dereference at your own risk).

Don't like that? Well, neither did a lot of people... microsoft/TypeScript#13778, "Option to include undefined in index signatures", was open for a long time and received hundreds of upvotes. And so with TypeScript 4.1 the --noUncheckedIndexedAccess compiler option was introduced. If you enable that, you will now receive a possibly-undefined value when you read from an index signature but you can't write one there. Hooray!

But it's not part of the --strict suite of compiler options. Maybe if this feature had been introduced with --strictNullChecks in TypeScript 2.0 it would have been. But a lot of real-world code currently expects that index signature properties are present, and it would break if this were introduced. And it could be annoying. The simple iterating through an array by index, like for (let i = 0; i < arr.length; i ) { sum = arr[i]*arr[i] }, suddenly isn't acceptable. You need for (let i = 0; i < arr.length; i ) { const v = arr[i]; if (v !== undefined) { sum = v * v } } now. That's an extra hoop to jump through and if you don't use index signatures with arbitrary keys then you're not going to be happy about it.

So that's index signatures.


And you are combining them. Well, even with --exactOptionalPropertyTypes and --noUncheckedIndexedAccess enabled, Partial<Record<string, number>> is going to explicitly include undefined and therefore let you write undefined to its property values:

// @exactOptionalPropertyTypes: true
// @noUncheckedIndexedAccess
type X = Partial<Record<string, number>>;
// type X = { [x: string]: number | undefined; }

const x: X = {};
x.hello = undefined; // no error!

This is... a bug, I guess? Or a design limitation? It's filed at microsoft/TypeScript#46969 "exactOptionalPropertyTypes: Partial of index signature adds undefined" and marked as "Needs Investigation" as of TypeScript 4.8.

So no matter what you're looking for, you probably don't want to use Partial<{[k: string]: XXX}> at all.

Instead, you should think about what behavior you want given the available options above and their tradeoffs. If you are planning to only iterate over the properties of your object, then I'd say you should just use Record<string, number> (leave off Partial) and leave the default --strict options. If you are planning to index into your object's properties with arbitrary keys, then you should decide between the default settings and adding | undefined manually (accepting that undefined values may show up when you do iterate) or enable --noUncheckedIndexedAccess and cause every index signature in your code base to start getting really worried about undefined. It's up to you.

Playground link to code

  • Related