iTranslated by AI
any Strikes Again: The Pitfalls of Arrays and Homomorphic Mapped Types
TypeScript is a fairly large OSS project, even though it is developed by a corporation, and the number of open issues recently exceeded 5,000. Numerous issues are created every day, and some are closed. Since TypeScript is such a large OSS, many issues face a difficult fate even if they are not closed. If the TypeScript team is not interested, a proposal is given the Suggestion and Await More Feedback labels, and even if it gets over 100 👍, it will never be unearthed again unless a miracle happens (by "miracle," I mean something like Anders implementing an interesting new feature for TypeScript on a whim and happening to solve it in the process). Also, non-urgent bugs are tagged with the bug label and thrown into the Backlog, where nothing will happen until they catch the eye of an OSS contributor looking for a chance to contribute to TypeScript.
For the various issues that the TypeScript team fortunately takes an interest in, someone from the team is assigned, or specific milestones are given. Also, depending on the issue, the direction may be discussed in a "Design Meeting" within the TypeScript team. The minutes of these Design Meetings are irregularly published on GitHub and make for quite interesting reading.
In this article, I will introduce a problem and its solution discussed in one such Design Meeting that I found interesting. In short, it is a problem where even if a homomorphic mapped type is intended to result in an array, providing any results in a non-array type.
What is a Homomorphic Mapped Type?
Unless you are very well-versed in TypeScript, you might not be familiar with the term homomorphic mapped type. This term refers to a mapped type of a specific form; basically, think of a mapped type in the following form as being a homomorphic mapped type.
{ [K in keyof 型1]: 型2 }
The point is that by doing this, it is made explicit that this mapped type creates a new type by inheriting each property of the object type pointed to by 型1.
Homomorphic mapped types exhibit special behaviors that ordinary mapped types do not. Unless I am forgetting something, these special behaviors can be summarized into the following three points:
- They inherit modifiers from the original type (like 型1 in the example above).
- If the original type is an array or tuple type, the result of the homomorphic mapped type is also an array or tuple type.
- (Only when 型1 is a type parameter) They perform union distribution.
The following example demonstrates that homomorphic mapped types inherit modifiers.
// This is a homomorphic mapped type
type HMT<T> = { [K in keyof T]: T[K][] };
// This is NOT a homomorphic mapped type
type NotHMT<T> = { [K in Extract<keyof T, unknown>]: T[K][] };
type FooObj = { readonly foo: string };
// type A = { readonly foo: string[] }
type A = HMT<FooObj>;
// type B = { foo: string[] }
type B = NotHMT<FooObj>;
In this example, we use HMT<T>, which is a homomorphic mapped type, and NotHMT<T>, which is not homomorphic. They differ in whether they use keyof T or Extract<keyof T, unknown>, but these type calculations should always result in the same outcome. However, when used within a mapped type, the latter prevents it from being recognized as homomorphic.
This distinction causes the difference between A and B. Since A uses a homomorphic mapped type, the readonly modifier from the original FooObj is preserved. In contrast, it is not preserved in B.
Next, let's look at an example with a tuple type.
type HMT<T> = { [K in keyof T]: T[K][] };
type NotHMT<T> = { [K in Extract<keyof T, unknown>]: T[K][] };
type Tuple = [string, number, boolean];
// type A = [string[], number[], boolean[]]
type A = HMT<Tuple>;
// type B = {
// [x: number]: (string | number | boolean)[];
// [Symbol.iterator]: (() => IterableIterator<string | number | boolean>)[];
// (omitted)
// includes: ((searchElement: string | ... 1 more ... | boolean, fromIndex?: number | undefined) => boolean)[];
// }
type B = NotHMT<Tuple>;
When we feed the tuple type Tuple into the same HMT and NotHMT as before, we get a new tuple type (A) in the case of HMT. You can see that the T[K][] part is applied to each element of the tuple type.
On the other hand, if it is not an HMT, the result is a plain object type. And the result (B) is quite overwhelming. This is literally every property of the tuple type mapped normally. Since the value of a tuple type is an array, the properties of a tuple type are all the properties and methods of an array. In the example above, you can see things like [Symbol.iterator] and includes.
There is a reasonable demand for processing the contents of array or tuple types, and homomorphic mapped types are necessary for that.
Problems Revealed in TypeScript 4.5 Beta
In this article, we will focus specifically on homomorphic mapped types for tuple types. What kind of use cases can you think of?
As one example, let's consider the following:
type PromiseContent<P> = P extends Promise<infer V> ? V : never;
type HMT<T extends readonly Promise<unknown>[]> = { [K in keyof T]: PromiseContent<T[K]> };
type Promises = [Promise<string>, Promise<string>, Promise<number>];
// type C = [string, string, number]
type C = HMT<Promises>;
Surprisingly, by using homomorphic mapped types, we were able to create a tuple type containing the contents of Promises from a tuple type containing the Promises themselves. With this, one specific application should come to mind. Yes, Promise.all. Promise.all has the functionality to wait until all the passed Promises are fulfilled, and the result is obtained as an array. When Promises with different result types are passed, we want to maintain those types in the return type, so tuple types come into play.
The type definition of Promise.all was rewritten in TypeScript 4.5 Beta to use this homomorphic mapped type. Conversely, the traditional type definitions used tuple types but did not use homomorphic mapped types.
all<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>, T7 | PromiseLike<T7>, T8 | PromiseLike<T8>, T9 | PromiseLike<T9>, T10 | PromiseLike<T10>]): Promise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]>;
all<T1, T2, T3, T4, T5, T6, T7, T8, T9>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>, T7 | PromiseLike<T7>, T8 | PromiseLike<T8>, T9 | PromiseLike<T9>]): Promise<[T1, T2, T3, T4, T5, T6, T7, T8, T9]>;
all<T1, T2, T3, T4, T5, T6, T7, T8>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>, T7 | PromiseLike<T7>, T8 | PromiseLike<T8>]): Promise<[T1, T2, T3, T4, T5, T6, T7, T8]>;
all<T1, T2, T3, T4, T5, T6, T7>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>, T7 | PromiseLike<T7>]): Promise<[T1, T2, T3, T4, T5, T6, T7]>;
all<T1, T2, T3, T4, T5, T6>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>, T6 | PromiseLike<T6>]): Promise<[T1, T2, T3, T4, T5, T6]>;
all<T1, T2, T3, T4, T5>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>, T5 | PromiseLike<T5>]): Promise<[T1, T2, T3, T4, T5]>;
all<T1, T2, T3, T4>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>, T4 | PromiseLike<T4>]): Promise<[T1, T2, T3, T4]>;
all<T1, T2, T3>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>, T3 | PromiseLike<T3>]): Promise<[T1, T2, T3]>;
all<T1, T2>(values: readonly [T1 | PromiseLike<T1>, T2 | PromiseLike<T2>]): Promise<[T1, T2]>;
all<T>(values: readonly (T | PromiseLike<T>)[]): Promise<T[]>;
In other words, for 2 to 10 elements, it had separate overloaded signatures for each case, and it gave up on 11 or more elements (falling into the signature at the very bottom that handles array types instead of tuple types).
However, with homomorphic mapped types, such an overwhelming type definition is no longer necessary. Consequently, in TypeScript 4.5 Beta, it became as follows:
all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;
Just this one line. The progress of technology is wonderful. A homomorphic mapped type is used in the return value. Awaited has appeared here, which is also a definition added to the standard library in TypeScript 4.5 Beta. It feels as though the type definitions for Promise.all and others were improved as a byproduct of adding Awaited.
By the way, the constraint extends readonly unknown[] | [] on the type parameter might seem like the | [] is redundant at first glance, but this is a trick to tell TypeScript to infer T as a tuple type instead of an array type when an array literal is passed as an argument. Instead of | [], the trick of using values: [...T] instead of values: T is also possible. Use whichever you prefer.
Everything seemed fine... until a problem came to light. Yes, the problem child any mentioned in the title. As reported in the following issue, the behavior when passing an any type value to Promise.all became significantly different from before.
const nazo: any = 123;
// It would be nice if it were any[], but...
const res = await Promise.all(nazo);
In TypeScript 4.4, res was of type [unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown]. While that itself is questionable, the information that the value returned by Promise.all is an array was preserved. On the other hand, in TypeScript 4.5 Beta, it becomes the type { [x: string]: any; }. This is the result of passing the any type through the aforementioned homomorphic mapped type. Even though there is a constraint T extends readonly unknown[] | [] on the type parameter, which might lead one to believe T is always an array or tuple type, any actually slips through the constraint and enters T. Since any itself is not treated as an array type, the homomorphic mapped type ends up generating a non-array object type. This broke code that depends on res being an array type. This is the problem that occurred in TypeScript 4.5 Beta.
Solution
Now, we can see that the solution to this problem is discussed in the Design Meeting Notes mentioned at the beginning. A Pull Request has already been submitted.
This homomorphic mapped type is in the form of keyof T, and there is information about the constraint T extends readonly unknown[] | []. Therefore, in this PR, even if any is passed to the homomorphic mapped type, if it can be determined from the constraint of the type variable containing that any that an array is intended, the result of the homomorphic mapped type will be an array type.
Looking at the Design Meeting Notes, there didn't seem to be much objection to this direction. However, there seem to be various detailed discussions. For example, whether the result should reflect the number of elements in a tuple type if it's known from the constraint, rather than just being a simple array type. It is not reflected in the current implementation, and since no conclusion was recorded in the Notes, it's unknown how it will turn out. There was also an opinion recorded saying, "Since there's no type reliability anyway when passing any, why bother with the number of elements in the return value? (heavily paraphrased)[1]"
type F<T extends [unknown, unknown]> = { [K in keyof T]: T[K][] };
// In the current PR implementation, type A = any[][]
type A = F<any>;
Furthermore, examples like the following were also brought up for discussion. Given that such test cases are prepared in the PR, they might be quite particular about it. The following IndirectArrayish is an example where the result still does not become an array type, even with this PR.
type Objectish<T extends unknown> = { [K in keyof T]: T[K] };
type IndirectArrayish<U extends unknown[]> = Objectish<U>;
// type A = { [x: string]: any; }
type A = IndirectArrayish<any>;
In this example, even if you pass any to Objectish, the return value naturally doesn't become an array type because it is T extends unknown and does not have an array type as a constraint. So, what if it goes through IndirectArrayish? In this case, there is a constraint U extends unknown[], so it seems like it could return an array type if that were used during the evaluation of Objectish<U>. However, in the current specification, the constraint U extends unknown[] of IndirectArrayish is only valid within the definition of IndirectArrayish and is not carried over when evaluating the inside of Objectish. Therefore, the result does not become an array type even when using IndirectArrayish.
If you wanted the result of IndirectArrayish<any> to be an array type, the constraints of the type parameters would need to be propagated. The conclusion on whether to propagate constraints was marked as "let's take it offline," so that too remains unknown. Personally, I don't think they need to go that far.
Summary
When using homomorphic mapped types with the intention of having the result be an array type, there is a risk of unintended behavior if any is passed. It truly is a problem child. However, in TypeScript 4.5, by ensuring that an array type like any[] is returned in such cases, it is expected that we will be able to use homomorphic mapped types with more peace of mind. I'm looking forward to it.
Those who want to follow the detailed specifications should watch the aforementioned PR.
-
Original: If an
anyflows in, should you really say the output has the right arity? ↩︎
Discussion