Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved mapped type support for arrays and tuples #26063

Merged
merged 4 commits into from
Jul 31, 2018

Conversation

ahejlsberg
Copy link
Member

This PR improves our support for arrays and tuples in homomorphic mapped types (i.e. structure preserving mapped types of the form { [P in keyof T]: X }). When a homomorphic mapped type is applied to an array or tuple type, we now produce a corresponding array or tuple type where the element type(s) have been transformed.

type Box<T> = { value: T };
type Boxified<T> = { [P in keyof T]: Box<T[P]> };

type T1 = Boxified<string[]>;  // Box<string>[]
type T2 = Boxified<ReadonlyArray<string>>;  // ReadonlyArray<Box<string>>
type T3 = Boxified<[number, string?]>;  // [Box<number>, Box<string>?]
type T4 = Boxified<[number, ...string[]]>;  // [Box<number>, ...Box<string>[]]
type T5 = Boxified<string[] | undefined>;  // Box<string>[] | undefined
type T6 = Boxified<(string | undefined)[]>;  // Box<string | undefined>[]

Previously, we would treat array and tuple types like regular object types and transform all properties (including methods) of the arrays and tuples. This behavior is rarely if ever desired.

Given a homomorphic mapped type { [P in keyof T]: X }, where T is some type variable, the mapping operation depends on T as follows (the first two rules are existing behavior and the remaining are introduced by this PR):

  • If T is a primitive type no mapping is performed and the result is simply T.
  • If T is a union type we distribute the mapped type over the union.
  • If T is an array type S[] we map to an array type R[], where R is an instantiation of X with S substituted for T[P].
  • If T is an array type ReadonlyArray<S> we map to an array type ReadonlyArray<R>, where R is an instantiation of X with S substituted for T[P].
  • If T is a tuple [S0, S1, ..., Sn] we map to a tuple type [R0, R1, ..., Rn], where each Rx is an instantiation of X with the corresponding Sx substituted for T[P].

Homomorphic mapped types can use ?, -?, or +? annotations to modify the optional-ness of tuple element types. For example, the predefined Partial<T> and Required<T> types have the expected effects on tuple element types:

type T10 = Partial<[number, string]>;  // [number?, string?]
type T11 = Required<[number?, string?]>;  // [number, string]

In --strictNullChecks mode the ?, -?, or +? annotations also add or remove undefined from the element type(s) of arrays and tuples:

type T20 = Partial<string[]>;  // (string | undefined)[]
type T21 = Required<(string | undefined)[]>;  // string[]

A readonly, -readonly, or +readonly annotation in a homomorphic mapped type currently has no effect on array or tuple elements (we might consider mapping from Array to ReadonlyArray and vice versa, although that technically isn't structure preserving because it adds or removes methods).

Homomorphic mapped type support for tuples makes it possible to transform variable length parameter lists, eliminating the need for repetitive patterns overloads in several scenarios. For example:

type Awaited<T> = T extends PromiseLike<infer U> ? U : T;
type Awaitified<T> = { [P in keyof T]: Awaited<T[P]> };

declare function all<T extends any[]>(...values: T): Promise<Awaitified<T>>;

function f1(a: number, b: Promise<number>, c: string[], d: Promise<string[]>) {
    let x1 = all(a);  // Promise<[number]>
    let x2 = all(a, b);  // Promise<[number, number]>
    let x3 = all(a, b, c);  // Promise<[number, number, string[]]>
    let x4 = all(a, b, c, d);  // Promise<[number, number, string[], string[]]>
}

This PR implements much of what is suggested in #25947, but without introducing new syntax.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small question/comment.

// to a union type A | B, we produce { [P in keyof A]: X } | { [P in keyof B]: X }. Furthermore, for
// homomorphic mapped types we leave primitive types alone. For example, when T is instantiated to a
// union type A | undefined, we produce { [P in keyof A]: X } | undefined.
// For a momomorphic mapped type { [P in keyof T]: X }, where T is some type variable, the mapping
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo:homomorphic

const elementTypes = map(tupleType.typeArguments || emptyArray, (_, i) =>
instantiateMappedTypeTemplate(mappedType, getLiteralType("" + i), i >= minLength, mapper));
const modifiers = getMappedTypeModifiers(mappedType);
const newMinLength = modifiers & MappedTypeModifiers.IncludeOptional ? 0 :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth extracting this calculation into a function.

@@ -10100,7 +10109,11 @@ namespace ts {
if (typeVariable !== mappedTypeVariable) {
return mapType(mappedTypeVariable, t => {
if (isMappableType(t)) {
return instantiateAnonymousType(type, createReplacementMapper(typeVariable, t, mapper));
const replacementMapper = createReplacementMapper(typeVariable, t, mapper);
return isArrayType(t) ? createArrayType(instantiateMappedTypeTemplate(type, numberType, /*isOptional*/ true, replacementMapper)) :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isOptional doesn’t have any meaning for arrays, does it? Why not pass false if that’s true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isOptional indicates whether a -? modifier on the mapped type should strip undefined from the source type. We want to do that when the source type originates in an optional property, but we also want to do it when the source type is an array element type.

@ahejlsberg ahejlsberg added this to the TypeScript 3.1 milestone Jul 31, 2018
@ahejlsberg ahejlsberg merged commit 4bc7f15 into master Jul 31, 2018
@ahejlsberg ahejlsberg deleted the mappedTypesArraysTuples branch July 31, 2018 17:54
@DanielRosenwasser DanielRosenwasser mentioned this pull request Aug 1, 2018
4 tasks
@jcalz
Copy link
Contributor

jcalz commented Aug 1, 2018

First: this is awesome.

Next: I know there were questions around still being able to map arrays/tuples like objects if the need arises. Below are some constructs that seem to allow that (PromisifySomeKeysTuple and PromisifyIntersectionTuple); the question is: are they intended or unintended?

type PromisifyNormal<T> = { [K in keyof T]: Promise<T[K]> }
type PromisifyNormalObject = PromisifyNormal<{ a: string, b: number, c: boolean }>
// {a: Promise<string>, b: Promise<number>, c: Promise<boolean>}
type PromisifyNormalTuple = PromisifyNormal<[string, number, boolean]>;
// [Promise<string>, Promise<number>, Promise<boolean>]

type PromisifySomeKeys<T, KT extends keyof T = keyof T> = { [K in KT]: Promise<T[K]> }
type PromisifySomeKeysObject = PromisifySomeKeys<{ a: string, b: number, c: boolean }>
// {a: Promise<string>, b: Promise<number>, c: Promise<boolean>}
type PromisifySomeKeysTuple = PromisifySomeKeys<[string, number, boolean]>;
/* 
type PromisifySomeKeysTuple = {
  [x: number]: Promise<string | number | boolean>;
  "0": Promise<string>;
  "1": Promise<number>;
  "2": Promise<boolean>;
  length: Promise<3>;
  ...
}
*/

type PromisifyIntersectionTuple = PromisifyNormal<[string, number, boolean] & { randomProp: 1234 }>;
/* 
type PromisifyIntersectionTuple = {
  [x: number]: Promise<string | number | boolean>;
  "0": Promise<string>;
  "1": Promise<number>;
  "2": Promise<boolean>;
  length: Promise<3>;
  ...
  randomProp: Promise<1234>;
}
*/

@Roaders
Copy link

Roaders commented Aug 4, 2018

This is really cool and I actually just asked if this was possible in 3.0 here.

I have had a play with this with the insiders build and it does exactly what I want:

class Maybe<T>{}

type MaybeTuple = [Maybe<string>, Maybe<number>, Maybe<boolean>];

type MaybeType<T> = T extends Maybe<infer MaybeType> ? MaybeType : never;
type MaybeTypes<T> = {[P in keyof T]: MaybeType<T[P]>};

type extractedTypes = MaybeTypes<MaybeTuple>;

my only comment would be that the type of extractedTypes when you mouse over it is:

type extractedTypes = {
    [x: number]: string | number | boolean;
    "0": string;
    "1": number;
    "2": boolean;
    length: {};
    includes: {};
    toString: {};
    toLocaleString: {};
    push: {};
    pop: {};
    concat: {};
    join: {};
    reverse: {};
    shift: {};
    slice: {};
    sort: {};
    splice: {};
    unshift: {};
    indexOf: {};
    lastIndexOf: {};
    every: {};
    some: {};
    forEach: {};
    map: {};
    filter: {};
    reduce: {};
    reduceRight: {};
    entries: {};
    keys: {};
    values: {};
    find: {};
    findIndex: {};
    fill: {};
    copyWithin: {};
}

whereas I would expect [string, number, boolean]

@Roaders
Copy link

Roaders commented Aug 4, 2018

As far as I can tell this PR does not help in the case of the rxjs pipe function:

    pipe<A>(op1: OperatorFunction<T, A>): Observable<A>;
    pipe<A, B>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>): Observable<B>;
    pipe<A, B, C>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>): Observable<C>;
    pipe<A, B, C, D>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>): Observable<D>;
    pipe<A, B, C, D, E>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>, op4: OperatorFunction<C, D>, op5: OperatorFunction<D, E>): Observable<E>;

here the second type param of one element in the tuple is the first type param of the next element in the tuple.

Is this case currently supported?

@dragomirtitian
Copy link
Contributor

@ahejlsberg I've been playing a bit with this and the compiler does seem not recognize the fact that a mapped array type will still be an array type for the purpose of rest parameters. Ex on 3.1.0-dev.20180813 i get this error:

type Promisify<T> = { [P in keyof T]: Promise<T[P]> };
// error:  A rest parameter must be of an array type.
async function  invokeWhenReady<T extends any[], R>(fn : (...a: T) => R, ...a: Promisify<T>) : Promise<R>{
    return null as any;
}

Is this a bug, or design limitation. Will it be addressed in the future ?

@jcalz
Copy link
Contributor

jcalz commented Aug 15, 2018

@dragomirtitian I think that's being tracked in #26163.

@jack-williams
Copy link
Collaborator

@jcalz I don't think that issue fixes the specific case of rest types because they use a different assignment relation. I think the problem is that the rest type checking uses isRestParameterType, which in turn uses assignable relation, not the definitely assignable relation that was fixed.

@ahejlsberg
Copy link
Member Author

@dragomirtitian @jcalz @jack-williams The invokeWhenReady example above works with #26676. For example:

declare let ps: Promise<string>;
declare let px: Promise<number[]>;

const r = invokeWhenReady((s, x) => ({ s, x }), ps, px)  // Promise<{ s: string, x: number[] }>

jablko added a commit to jablko/TypeScript that referenced this pull request Dec 28, 2019
@fabb fabb mentioned this pull request Jan 1, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Jan 21, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Jan 25, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Jan 25, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Jan 29, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 3, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 3, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 4, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 6, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 9, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 10, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 11, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 12, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 12, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 13, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 21, 2020
jablko added a commit to jablko/TypeScript that referenced this pull request Feb 22, 2020
@klesun
Copy link

klesun commented Nov 26, 2021

For anyone who is struggling with prototype properties getting mapped as well (like in @Roaders's #26063 (comment)), causing A rest parameter must be of an array type error, I found out that it can be solved by explicitly keeping prototype properties out of boxing:

type Boxified<T> = {
    [P in keyof T]: P extends keyof [] ? T[P] : Box<T[P]>
};

instead of

type Boxified<T> = {
    [P in keyof T]: Box<T[P]>
};

(It worked implicitly in v4.4.4, but seems to require explicit condition since 4.5.2, at least in a more complicated scenario)

@jedwards1211
Copy link

@ahejlsberg it appears this only works on type aliases, rather than any homomorphic mapped type?

type Tuple = [1, 2];
type A<T> = { [K in keyof T]: string };

type WithAlias = A<Tuple> // [string, string]

type WithoutAlias = { [K in keyof Tuple]: string };
/*
  {
    [x: number]: string;
    0: string;
    1: string;
    length: string;
    ...
 */

It's always been surprising to me when the left and right hand sides of a type alias aren't interchangeable. At this point I wish an operator like := had been used for TS type aliases.

@RyanCavanaugh
Copy link
Member

The higher-order behavior only applies to generics, so bare object type expressions are never homomorphic; only aliases are.

@jedwards1211
Copy link

Sometimes I wish I could do a one-off tuple mapping inline instead of having to declare a type alias out of line. And very often, I wish I could distribute over a union type inline instead of using a distributive conditional type alias

@jcalz
Copy link
Contributor

jcalz commented Nov 9, 2023

See #27995

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.