Reputation: 95
I'm trying to define types for a currying function in TypeScript. The JavaScript version is as follows:
function curry1(fn) {
return (x) => (fn.length === 1 ? fn(x) : curry1(fn.bind(undefined, x)));
}
This works perfectly well -- applying curry1
to a function produces a unary function. If you call that function with an argument, it will produce another unary function, that when called with an argument will produce yet another unary function, etc., until all parameters have been provided, in which case it will produce a result. Currying f1
below produces a function equivalent to (a)=>(b)=>(c)=>result
.
const make3 = (a, b, c) => `${a}:${b}:${c}`;
const f1 = curry1(make3);
const f2 = f1('A');
const f3 = f2(2);
const f4 = f3('Z');
console.log(f4); // A:2:Z
I defined a generic CURRY1
type that essentially returns a function that will produce a result (if given a unary function) or a curried function with one fewer argument (if given a function with two or more arguments).
type CURRY1<P extends any[], R> = P extends [infer H]
? (arg: H) => R // only 1 arg?
: P extends [infer H0, infer H1, ...infer T] // 2 or more args?
? (arg: H0) => CURRY1<[H1, ...T], R>
: never;
and then
function curry1<P extends any[], R>(fn: (...args: P) => R): CURRY1<P, R> {
return (x: any): any => (fn.length === 1 ? fn(x) : curry1(fn.bind(undefined, x)));
}
If I write
const make3 = (a: string, b: number, c: string): string => `${a}:${b}:${c}`;
and write f1 = curry1(make3)
VSCode correctly shows that the type of f1
is (arg: string) => (arg: number) => (arg: string) => string
. However, TypeScript objects, saying Type '(x: any) => any' is not assignable to type 'CURRY1<P, R>' ts(2322)
. If I add a // @ts-ignore
line before the return
, the code works perfectly well. But how can I avoid having that error otherwise?
Upvotes: 2
Views: 1139
Reputation: 327994
In what follows I'm going to rewrite your types like this:
interface Curry1<AH, AT extends any[], R> {
(firstArg: AH): AT extends [infer ATH, ...infer ATT] ? Curry1<ATH, ATT, R> : R;
}
declare function curry1<AH, AT extends any[], R>(
fn: (firstArg: AH, ...restArgs: AT) => R
): Curry1<AH, AT, R>;
This is essentially the same as what you've got, but it makes it impossible to even pass in a zero-argument function (there's always a first argument of type AH
(the H
ead of the A
rguments tuple).
It isn't currently possible for the compiler to verify that curry1
is implemented properly, since it needs to return a conditional type that depends on as-yet unspecified generic type parameters AH
, AT
, and R
.
The check fn.length === 1
could conceivably have a narrowing effect on the value fn
(although TS doesn't actually do this, see Function overloading and type inference in typescript for more info), but it has no effect whatsoever on the type parameter AT
(the T
ail of the A
rguments tuple type). There is currently no mechanism whereby control flow analysis does anything to re-constrain type parameters.
And so, for example, even if fn.length === 1
, the type AT
is not re-constrained to the empty tuple []
, and so AT extends [infer ATH, ...infer ATT] ? Curry1<ATH, ATT, R> : R
cannot be simplified to R
, and so fn(firstArg)
is not seen as a valid return value.
There is an open feature request at microsoft/TypeScript#33912 asking for better support here. But for now, the best you can do is equivalent to a type assertion to suppress the compiler errors inside the implementation.
Usually in cases like this, I tend to write the function in question as an overloaded function with a single call signature. Overloads require you to declare the call signatures, and then write a separate implementation. This implementation is checked against the call signatures somewhat loosely, and so it is a rather convenient way to separate the concerns of the callers (who want useful strong types) and the implementer (who wants to implement the function and move on with their life). It is this slightly-off-label convenience I'm taking advantage of here, not the as-advertised ability to have multiple call signatures.
Anyway, it would look like this:
// call signature with strong typing
function curry1<AH, AT extends any[], R>(
fn: (firstArg: AH, ...restArgs: AT) => R
): Curry1<AH, AT, R>;
// implementation signature with (relatively) loose typing
function curry1(fn: (...args: any) => any) {
return (firstArg: any) => (fn.length === 1 ? fn(firstArg) : curry1(fn.bind(undefined, firstArg)));
}
You can see that I'm using any
a lot in the implementation; the type checker is unable to verify what I'm doing as safe, so I'm mostly asking it to stay out of my way. That also means I need to be careful with the implementation, since any mistake I make will not be caught by the type checker.
Okay, let's try it out:
const make3 = (a: string, b: number, c: string) => `${a}:${b}:${c}`;
const f1 = curry1(make3);
const f2 = f1('A');
const f3 = f2(2);
const f4 = f3('Z');
console.log(f4); // A:2:Z
This all behaves as you want and expect, since it only depends on the call signatures.
Note that the implementation isn't completely safe, since there's only a loose relationship between the length
property of a function and the number of arguments passed to it. Functions can have optional elements (the function (a = 1) => a
has a length of 0
) and rest parameters (the function (...args: any[]) => args)
also has a length of 0
), and sometimes TypeScript has a different idea about the number of arguments than JavaScript does (the Math.max
method, for example has a length
of 2
, even though it's variadic and TypeScript sees it as if it were 0
). So you can get some weird edge cases:
const oops = curry1(Math.max);
const oops2 = oops(2);
// const oops2: number;
oops2.toFixed() // error! oops2.toFixed is not a function
If this matters, I think the only safe option is to change your currying so that there's an explicit "call the function" step somewhere. But that's out of scope for the question as asked, so I won't go into it further.
Upvotes: 2