I would like to create a TypeScript 5 method decorator with parameters, for logging purpose.
A parameter mappers
could allow to map initial function arguments to arbitrary values.
My first difficult was to create a type from method arguments.
I found this great article (note: outdated because it was written before variadic tuples existed) and ended up with a working version (at least I assumed it worked).
type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never;
type Tail<T extends any[]> = ((...t: T) => any) extends (_: any, ...tail: infer TT) => any ? TT : [];
type Length<T extends any[]> = T['length'];
type IsRest<T, P extends any[]> = Required<T> extends never ? number extends Length<P> ? true : false : false;
type ParamsToFunc<T extends any[], R = any, RT extends any[] = Required<T>> = [
...(
IsRest<Head<T>, T> extends true
? [(...v: T) => R]
: Head<T> extends never
? Head<RT> extends never
? []
: [(v?: Head<RT>) => R]
: [(v: Head<T>) => R]
),
...(
IsRest<Head<T>, T> extends true
? []
: Length<Tail<T>> extends 0
? []
: ParamsToFunc<Tail<T>, R>
),
];
Here are tests
type T01 = ParamsToFunc<Parameters<() => string>>;
// []
type T02 = ParamsToFunc<Parameters<(a: string) => string[]>>;
// [(v: string) => any]
type T03 = ParamsToFunc<Parameters<(a: string[]) => number>>;
// [(v: string[]) => any]
type T04 = ParamsToFunc<Parameters<(a: undefined, b: string[]) => number>>;
// [(v: undefined) => any, (v: string[]) => any]
type T05 = ParamsToFunc<Parameters<(a: undefined, ...rest: string[]) => number>>;
// [(v: undefined) => any, (...v: string[]) => any]
type T06 = ParamsToFunc<Parameters<(...rest: string[]) => number>>;
// [(...v: string[]) => any]
type T07 = ParamsToFunc<Parameters<(a: string[], ...rest: string[]) => number>>;
// [(v: string[]) => any, (...v: string[]) => any]
type T08 = ParamsToFunc<Parameters<(a: string, b: number) => number>>;
// [(v: string) => any, (v: number) => any]
type T09 = ParamsToFunc<Parameters<(a: string, b: number, ...rest: string[]) => number>>;
// [(v: string) => any, (v: number) => any, (...v: string[]) => any]
type T10 = ParamsToFunc<Parameters<(options?: boolean) => Promise<number>>>;
// [(v?: boolean | undefined) => any]
type T11 = ParamsToFunc<Parameters<(options?: boolean, other?: boolean) => Promise<number>>>;
// [(v?: boolean | undefined) => any, (v?: boolean | undefined) => any]
type T12 = ParamsToFunc<Parameters<(a: string | number, to: string, options?: boolean) => Promise<number>>>;
// [(v: string | number) => any, (v: string) => any, (v?: boolean | undefined) => any]
So far so good. The type handles tricky cases like optional parameters or rest parameters.
Now, here is a simplified version of my method decorator factory (making it useless).
type TypedFunction<This, Args extends any[], Return> = (this: This, ...args: Args) => Return;
type LoggedMethodOptions<Args extends any[]> = { mappers: ParamsToFunc<Args> };
function loggedMethod<This, Args extends any[], Return>(_options?: LoggedMethodOptions<Args>) {
return function (
target: TypedFunction<This, Args, Return>,
_context: ClassMethodDecoratorContext<This, TypedFunction<This, Args, Return>>
) {
return function (this: This, ...args: Args): Return {
return target.call(this, ...args);
};
};
}
After spending time to create a proper type I expected things to be OK but I was wrong.
type FileId = string;
class FileService {
// OK
@loggedMethod({ mappers: [v => v.toUpperCase()] })
deleteFile(_id: FileId): void {}
// error "Parameter 'v' implicitly has an 'any' type.(7006)" for all mappers
// however I see
// (property) mappers: [(v: string) => any, (v: string) => any, (v?: { verbose?: boolean; } | undefined) => any]
@loggedMethod({ mappers: [v => v.toUpperCase(), v => v.toUpperCase(), v => v?.verbose] })
copyFile(_from: FileId, _to: FileId, _options?: { verbose?: boolean }): void { }
// OK (makes me sick because `copyFile` seems so close)
@loggedMethod({ mappers: [v => v.toUpperCase(), v => v.toUpperCase(), v => v.toUpperCase()] })
copyFile2(_from: FileId, _to: FileId, _3rdParty: FileId): void { }
// error "Parameter 'v' implicitly has an 'any' type.(7006)" for all mappers
// however I see
// property) mappers: [(v: string) => any, (v: any[]) => any]
@loggedMethod({ mappers: [v => v.toUpperCase(), v => v.length] })
saveToFile(_to: FileId, _contents: any[]): void { }
// OK
@loggedMethod({ mappers: [v => v.toUpperCase()] })
getFileAndMetadata(_desc: FileId): void { }
}
It seems that TypeScript considers the mapper input is of type any
as soon as there are initial argument with different types.
So, copyFile
has errors (FileId
+ FileId
+ other) while copyFile2
has none (FileId
+ FileId
+ FileId
). Still, the type shown for mappers
seems correct.
Here is a TypeScript Playground with all the code above (TypeScript 5.6.3 / target ES2022).
I don't understand why types seem OK when I hover the mouse over the definition and at the same time TypeScript complains.
In trying to understand what your ParamsToFunc<T>
does, I worked on writing my own more "direct" version (whether or not it's actually more direct is debatable; mine doesn't define extra utility types):
type ParamsToFunc<T extends any[], A extends any[] = []> =
T extends [infer F, ...infer R] ? ParamsToFunc<R, [...A, (v: F) => any]> :
T extends [] ? A :
T extends [(infer F)?, ...infer R] ? (
T extends R ? [...A, (...v: T) => any] : ParamsToFunc<R, [...A, (v?: F) => any]>
) : never;
This version works without errors. It reproduces the test cases, and the contextual type of callback parameters in things like
@loggedMethod({ mappers: [v => v.toUpperCase(), v => v.toUpperCase(), v => v.toUpperCase()] })
copyFile2(_from: FileId, _to: FileId, _3rdParty: FileId): void { }
happens properly; no "implicit any
" errors.
I'll briefly describe how my version of ParamsToFunc
, I first check T extends [infer F, ...infer R]
to see if it is a tuple with a required first element; if so, we recurse and add the "plain" function to the accumulator A
. Otherwise I check T extends []
to see if we've hit the end of the tuple; if so, we return the accumulator A
. Otherwise, I check T extends [(infer F)?, ...infer R]
to see if the tuple has an optional element. This will effectively be true no matter what; so the next check is T extends R
to see if the rest of the array and the current array are the same. If so, then we have hit a plain array or a rest element, since pulling the first element off the array doesn't change it. If we've hit that rest element then we return the accumulator with the "rest" function at the end. Otherwise we recurse and add the "optional" function to the accumulator A
. Then we've hit the impossible case where T extends [(infer F)?, ...infer R]
is false, so we return never
.
So this version works, but it doesn't necessarily answer why your version doesn't.
Going back to your definition, the minimum change I can make to it which causes it to work is:
type ParamsToFunc<T extends any[], R = any, RT extends any[] = Required<T>> =
IsRest<Head<T>, T> extends true ? [(...v: T) => R] :
[...(
Head<T> extends never ? (
Head<RT> extends never ? [] : [(v?: Head<RT>) => R]
) : [(v: Head<T>) => R]
), ...(
Length<Tail<T>> extends 0 ? [] : ParamsToFunc<Tail<T>, R>
)];
I've moved your IsRest<Head<T>, T>
tests out of the pair of variadic tuple pieces, combined them into a single test, and put it first. The rest of your code is essentially the same.
And again, the test cases are the same but now the callback parameters are always contextually typed.
This still doesn't answer why your version doesn't work, but it shows the location of the problem.
At this point you have two working versions but no definitive answer for why the other one didn't work. My guess is that, since decorators have to infer their generic type arguments contextually from the expected return type of the function, the order of inference can be different depending on the types involved. Your original version always uses variadic tuples, even for the nonrecursive/base case. The updated version does not. Why this should matter is unclear to me.
There are GitHub issues about similar problems, like microsoft/TypeScript#52047, but I can't be positive it's the same underlying situation. That one is considered a bug in TypeScript, but one that might not be trivial to fix.
So there you go. If this question is "how can I make it work", I've answered it. If it's primarily "why doesn't this work", then I've only posted guesswork, and you might need to file a GitHub issue to know for sure (but if you do this, I'd strongly recommend paring down the code to the absolute minimum which demonstrates the problem).