4

I want to replace the type of the last parameter in a function whilst preserving the names of all function parameters. In my case the last argument is also optional.

eg:

type OrigArg = { arg: number };
type ReplacedArg = { arg: string };

type OrigFunc = (a: number, b: string, c?: OrigArg) => string
type ReplaceLast<TFunc, TReplace> = // type I'm looking for

type ReplacedFunc = ReplaceLast<OrigFunc, ReplacedArg> 
// type ReplacedFunc = (a: number, b: string, c?: ReplacedArg) => string

A further complication is that the number and type of the preceding arguments is variable, I just know that the last argument will be of a certain type and I want to replace it with a custom one.

yahgwai
  • 43
  • 3

1 Answers1

8

Ok, this is a bit difficult and I'm going to explain it. Go down for TL;DR.

Parameter names in typescript are kept only while spreading original parameters, like:

type OrigParams = Parameters<OrigFunc>;
type AnotherFunc = (...args: OrigParams) => any;

This is due to TS nature where the Parameters tuple really is a special one, that keep the names as an inner, unaccessible, metadata that gets destroyed while changing the tuple or just creating another one. i.e.:

type F = (a: any, b: any) => void;
type P1 = Parameters<F>;
type P2 = [any, any];

here P1 and P2 are different types even if they are structurally the same.

Citing TS:

Note that when a tuple type is inferred from a sequence of parameters and later expanded into a parameter list, as is the case for U, the original parameter names are used in the expansion (however, the names have no semantic meaning and are not otherwise observable).

However, this can be done using mapped types on the parameters tuple, since mapping the tuple preserves the parameter names, and a bit of post-processing given the "last" item problem.

  1. In order to "change" the parameter, we can use a Mapped Type. Mapped types are applicable to tuples other than on objects, and Parameters is a tuple, so we came up with:
type ReplaceLastParam<TParams extends readonly any[], TReplace> = {
    [K in keyof TParams]: // We should put the replace code here
}
  1. The problem is that we should not change all the parameters, but only the last one. K is a string while mapping types, i.e. keyof TParams is "0" | "1" | "2" in case of a tuple with three elements. So, in the case we have only three parameters, we could easily write:
type ReplaceParam2<TParams extends readonly any[], TReplace> = {
    [K in keyof TParams]: K extends "2" ? TReplace : TParams[K] 
}
  1. The problem here is that we don't know the last index, since you are providing functions with dynamic number of arguments. To compute the last one, we can use an helper that tricks TS:
type LastIndex<T extends readonly any[]> =
    ((...t: T) => void) extends ((x: any, ...r: infer R) => void) ?  Exclude<keyof T, keyof R> : never;

This type computes the keys of a tuple R that has one elements less of a tuple T and diffs them with Exclude: so Exclude<"0" | 1" | "2", "0" | "1"> is exactly "2", our last item. Note that we are using type inference here and the function rest inference.

  1. We just put all together, adding a type to pass a function (ReplaceLast) to wrap the parameters changing type we just made:

TL;DR:

// Index helper
type LastIndex<T extends readonly any[]> =
    ((...t: T) => void) extends ((x: any, ...r: infer R) => void) ?  Exclude<keyof T, keyof R> : never;

// Replace last parameter with mapped type
type ReplaceLastParam<TParams extends readonly any[], TReplace> = {
    [K in keyof TParams]: K extends LastIndex<TParams> ? TReplace : TParams[K] 
}

// Replace function
type ReplaceLast<F, TReplace> = F extends (...args: infer T) => infer R
    ? (...args: ReplaceLastParam<T, TReplace>) => R
    : never;

Playground Link

This was fun! Hope this answers your question, however if you really need this in your application, I hope you know what you are doing since the requirement maybe could be solved in a simpler manner.

leonardfactory
  • 3,353
  • 1
  • 18
  • 25
  • 1
    Amazing, you have no idea the knots I was tying myself in trying to get this - most of which ended up zeroing out the function args names. For context, my specific use case is to write an extension to popular library. Each of these functions: https://docs.ethers.io/ethers.js/html/api-contract.html#contract-methods has an override property as it's last argument https://docs.ethers.io/ethers.js/html/api-contract.html#overrides and I to be able to replace those overrides with some custom ones. – yahgwai Apr 25 '20 at 12:44
  • Ah, sounds like a more than reasonable need so! Yeah this kind of things in TS is really a puzzle – leonardfactory Apr 25 '20 at 13:02
  • 1
    Nice! Didn't realize that `keyof` will result in indexes when mapping tuples/arrays – Aleksey L. Apr 26 '20 at 06:18
  • I have a similar problem, but my last args is a rest `export type FUNC = (arg1: any, arg2: any, ...rest: Array) => any; `. In that case LastIndex Helper will return "1" not "2". – Romain TAILLANDIER Jul 28 '22 at 10:12