Skip to content

Further improve mapped type support for tuples and arrays (almost complete Promisify<T> inside!) #26190

Description

@AlCalzone

Now that #26063 is merged (which is great BTW), I was toying around with an alternate solution to #1360 or #25717. The basic idea is to take the parameter list of a NodeJS callback style API as a tuple and generate two other types from it:

1. The type of the last item in the tuple, which can currently be achieved doing this:

// Helper type to drop the first item in a tuple, i.e. reduce its size by 1typeDrop1<Textendsany[]>=((...args: T)=>void)extends((a1: any, ...rest: infer R)=>void) ? R : never;typeTakeLast<Textendsany[],// Create a tuple which is 1 item shorter than T and determine its lengthL1extendsnumber=Drop1<T>["length"],// use that length to access the last index of T>=T[L1];// Example:typeFoo=TakeLast<[1,5,7,8,9,string]>;// string

2. The type of all items in the tuple BUT the last.
This one is tricky and isn't working 100% yet. The basic idea is to work with the reduced tuple again and compare indizes with the original tuple:

typeMapTuples<T1extendsany[],T2extendsany[]>={[KinkeyofT1]: KextendskeyofT2 ? T1[K] : never};typeBar=MapTuples<[1,2,3],[4,5]>;// [1, 2, never]typeBaz=MapTuples<[1,2],[3,4,5]>;// [1, 2]

As you can see, when T1 is longer than T2, the extra elements get converted to never. In comparison we can use this trick to erase properties from objects, which does not work here. As a result, when we use the following types to try and drop the last argument from a parameter list

typeDropLast<Textendsany[],MinusOneextendsany[]=Drop1<T>,>=MapTuples<T,MinusOne>;// DropLast<[1, 2, 3]> is [1, 2, never]// Returns the params of a function as a tupletypeParams<Fextends(...args: any[])=>void>=Fextends((...args: infer TFArgs)=>any) ? TFArgs : never;// creates a function type with one less argument than the given onetypeDropLastArg<Fextends(...args: any[])=>void,FArgsextendsany[]=Params<F>,RArgsextendsany[]=DropLast<FArgs>// ** ERROR **>=(...args: RArgs)=>void;

the last argument is still present, but now has type never

typeF1=DropLastArg<(arg1: number,arg2: string,arg3: boolean)=>void>;// F1 is (arg1: number, arg2: string, arg3: never) => void

In addition, the mapped tuple type is no longer recognized as a tuple [Symbol.iterator()] is missing in type MapTuples<...>, so we have to force RArgs to be one

typeForceTuple<T>=Textendsany[] ? T : any[];typeDropLastArg<Fextends(...args: any[])=>void,FArgsextendsany[]=Params<F>,RArgsextendsany[]=ForceTuple<DropLast<FArgs>>>=(...args: RArgs)=>void;

but now F1 has type (...args: any[]) => void because we lost the type information.

However with a few changes, we can get closer to the desired result:

// notice how we now Map from T1 to T2typeMapTuples<T1extendsany[],T2extendsany[]>={[KinkeyofT1]: KextendskeyofT2 ? T2[K] : never};// MapTuples<[1, 2], [4, 5, 6]> is [4, 5]typeDropLast<Textendsany[],// create a tuple that is 1 shorter than TMinusOneextendsany[]=Drop1<T>,// and map the entries to the ones at the corresponding indizes in T>=MapTuples<MinusOne,T>;// DropLast<[1, 2, 3]> is [1, 2] :)typeF1=DropLastArg<(arg1: number,arg2: string,arg3: boolean)=>void>;// F1 is (arg2: number, arg3: string) => void

Notice how F1 has the correct argument types, but the names are off by one!

Suggestion

So in conclusion I'd like to see some more improvements to mapped tuples, specifically:

  • the ability to remove items from them and
  • complex mapped tuples to still be recognized as tuples.

Use Cases

A BIG usecase is typing NodeJS callback-style APIs, which I came very close to type using this:

typeDrop1<Textendsany[]>=((...args: T)=>void)extends((a1: any, ...rest: infer R)=>void) ? R : never;typeTakeLast<Textendsany[],// Create a tuple which is 1 item shorter than T and determine its lengthL1extendsnumber=Drop1<T>["length"],// use that length to access the last index of T>=T[L1];typeMapTuples<T1extendsany[],T2extendsany[]>={[KinkeyofT1]: KextendskeyofT2 ? T2[K] : never};typeDropLast<Textendsany[],// create a tuple that is 1 shorter than TMinusOneextendsany[]=Drop1<T>,// and keep only the entries with a corresponding index in T>=MapTuples<MinusOne,T>;typeParams<Fextends(...args: any[])=>void>=Fextends((...args: infer TFArgs)=>any) ? TFArgs : never;typeForceTuple<T>=Textendsany[] ? T : any[];typeForceFunction<T>=Textends((...args: any[])=>any) ? T : ((...args: any[])=>any);typePromisify<Fextends(...args: any[])=>void,// Extract the argument typesFArgsextendsany[]=Params<F>,// Infer the arguments for the promisified versionPromiseArgsextendsany[]=ForceTuple<DropLast<FArgs>>,// Parse the callback argsCallbackArgsextendsany[]=Params<ForceFunction<TakeLast<FArgs>>>,CallbackLength=LengthOf<CallbackArgs>,TError=CallbackArgs[0],// And extract the return valueTResult=1extendsCallbackLength ? void : CallbackArgs[1]>=(...args: PromiseArgs)=>Promise<TResult>;

Examples

typeF1=(arg1: number,arg2: string,c: (err: Error,ret: boolean)=>void)=>void;typeF1Async=Promisify<F1>;// F1Async is (arg2: number, c: string) => Promise<boolean>; (YAY!)

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript / JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. new expression-level syntax)

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions