I recently tried to remove the first argument from the function signature:
type TwoArgsFunction = (state: {}, arg1: number) => string;
type OneArgFunction = RemoveFirstArg<TwoArgsFunction>;
type RemoveFirstArg = /* What goes here? */
Turns out it is nearly impossible! As of TypeScript 4.7, I only found one solution that works in all cases: Manually specifying both the initial and the transformed signature:
type WithCustomCurrySignature = {
CurriedSignature: Function;
}
type RemoveFirstArg< F > =
F extends WithCustomCurrySignature
? F['CurriedSignature']
: F extends ( state: any, ...args: infer P ) => infer R
? ( ...args: P ) => R
: F;
interface TwoArgsFunction extends WithCustomCurrySignature {
(state: {}, myNumberArg: number): string;
CurriedSignature: (myNumberArg: number) => string;
}
type OneArgFunction = RemoveFirstArg<TwoArgsFunction>;
The simple solution does not preserve generic types
I noticed this currying problem while working on adding the TypeScript support to WordPress. The Redux selectors are curried:
// In selectors.ts, the state is one of the arguments
export function getEntityConfig( state, kind, name ) { /* */ }
// The consumer code doesn't pass the state, though
select( coreStore ).getEntityConfig( kind, name );
The select
function needs to return a version of the getEntityConfig
that doesn’t expect the state
argument.
A simple solution would be to handle it as follows:
type RemoveFirstArg< F > = F extends ( state: any, ...args: infer P ) => infer R
? ( ...args: P ) => R
: F;
It works well with simple function signatures:
type TwoArgFunction = (state: {}, arg1: number) => string;
type OneArgFunction = RemoveFirstArg< TwoArgFunction >;
// OneArgFunction is (myNumberArg: number) => string;
Unfortunately, it doesn’t correctly curry functions using generics:
type Selector = <K extends string | number>(
state: any,
arg1: K,
arg1Type: K extends string ? 'string' : 'number'
) => K;
type IncorrectlyCurriedSignature = RemoveFirstArg< Selector >
// IncorrectlyCurriedSignature evaluates to:
// (arg1: string | number, arg1Type: "string" | "number") => string | number
A universal RemoveFirstArg
type may not exist in TypeScript
I don’t believe TypeScript supports the kind of automatic transformation the RemoveFirstArg
wants to do. I’ve spent hours trying to build one, and my every attempt failed.
Parameters type cannot be extracted from a generic function signature
The F extends ( state: any, ...args: infer Params ) => Return
feature does not correctly infer Params
nor Return
.
The Parameters
helper does not help either:
Parameters< Selector >
// [arg1: string | number, arg1Type: "string" | "number"]
Instantiation expressions can’t express the generic signature either
TypeScript has a new feature called “instantiation expressions” that allows you to say typeof selector<'root'>
:
function selector<K extends string | number>(
state: any,
arg1: K,
arg1Type: K extends string ? 'string' : 'number'
): K { return {} as K; };
type TypeOfSignature = typeof selector<'foo'>;
// (state: any, arg1: "foo", arg1Type: 'string') => 'foo';
The inferred signature reflects the constraints of the selector
definition, which is great. The problem now is that it’s only for the specific K
equal to root
. We want to make it work for all K
.
Unfortunately, passing a type union as K moves us back to square one:
type InstantiatedTypeOfSignature = typeof selector<'foo' | 10>;
// (state: any, arg1: "foo" | 10 , arg1Type: "string" | "number") => "foo" | 10
Parametrized clone doesn’t cut it either
I’ve also tried my luck with Parametrization
There aren’t many ways of formulating such a parametrized clone type I can think of. I only found the following Curry implementation on StackOverflow:
type FN1<A, R> = (a: A) => R
type FN2<A, B, R> = ((a: A, b: B) => R) | ((a: A) => FN1<B, R>)
type FN3<A, B, C, R> = ((a: A, b: B, c: C) => R) | ((a: A, b: B) => FN1<C, R>) | ((a: A) => FN2<B, C, R>)
interface RemoveFirstArg {
<A, R>(fn: (arg0:A) => R): FN1<A, R>
<A, B, R>(fn: (arg0:A, arg1:B) => R): FN2<A, B, R>
<A, B, C, R>(fn: (arg0:A, arg1:B, arg2:C) => R): FN3<A, B, C, R>
}
const removeFirstArg = {} as RemoveFirstArg;
removeFirstArg(selector);
Unfortunately, this causes an “insufficient constraints” error:
Types of parameters 'arg1' and 'arg1' are incompatible.
Type 'unknown' is not assignable to type 'string | number'
I didn’t find any way of specifying the generic type constraints that would make it work.
You can’t even slice a parametrized tuple
Once the type constraints are baked into a function type signature, they are stuck there. I haven’t found any TypeScript feature that could split the function apart into a constrained arguments type and a constrained return type.
But what would happen if we defined the arguments tuple manually?
type SelectorArgs<K extends string | number> = [
state: any,
arg1: K,
arg1Type: K extends string ? 'string' : 'number'
]
So far so good, now let’s try to remove its first element:
type SlicedArgs<K extends string | number> = SelectorArgs<K> extends [any, infer A1, infer A2] ? [A1, A2] : never;
const selectorWithSlicedArgs = <K extends string | number>(...args:SlicedArgs<K>) => { return {} as K };
selectorWithSlicedArgs( 'foo', 'string' ) // ✅ 'foo'
selectorWithSlicedArgs( 'foo', false ) // ✅ Type error
selectorWithSlicedArgs( 10, 'number' ) // ✅ 10
selectorWithSlicedArgs( 10, false ) // ✅ Type error
There is hope, TypeScript respects the original constraints. Let’s try a more complex SelectorArgs type, then:
type Character = { first: 'harry', last: 'potter' } | { first: 'severus', last: 'snape' };
type GetFirstName<FirstName extends string> = [
state: any,
first: FirstName,
last: Extract<Character, { first: FirstName }>['last']
]
type SlicedArgs<K extends string | number> = GetFirstName<K> extends [any, infer A1, infer A2] ? [A1, A2] : never;
const getName = <K extends string | number>(...args:SlicedArgs<K>) => null;
getFirstName( 'harry', 'potter' ) // ✅ 'harry'
getFirstName( 'harry', false ) // ✅ Type error
getFirstName( 'harry', 'snape' ) // ❌ 'harry'
getFirstName( 'foo', 'snape' ) // ❌ 'foo'
Bummer! Adding Extract
to the mix breaks the inference and yields a selectorWithSlicedArgs
that simply accepts 'one value' | false
as its second argument.
Interestingly, we still can provide the constraint manually:
getFirstName<'harry'>( 'harry', 'potter' ) // ✅ 'harry'
getFirstName<'harry'>( 'harry', 'snape' ) // ✅ Type Error
It’s cool, but it doesn’t help much.
This leaves us with only one option.
Providing the curried and uncurried type signatures manually is the only reliable option
I want to do two things with the selector
function
- Call it
- Transform its signature into a curried form
Let’s define a type that is both callable and also contains information about the manually-defined curried signature:
interface WithCustomCurrySignature {
CurriedSignature: Function;
}
type Character = { first: 'harry', last: 'potter' } | { first: 'severus', last: 'snape' };
interface GetFirstName extends WithCustomCurrySignature {
<FirstName extends string>(
state: any,
first: FirstName,
last: Extract<Character, { first: FirstName }>['last']
): FirstName;
CurriedSignature: <FirstName extends string>(
first: FirstName,
last: Extract<Character, { first: FirstName }>['last']
) => FirstName;
}
Here’s a proof it can be called just like a regular function:
const getFirstName = {} as GetFirstName;
getFirstName({}, 'harry', 'potter' ); // ✅ 'harry'
getFirstName({}, 'harry', 'snape' ); // ✅ Type error
getFirstName({}, 'harry', 10 ); // ✅ Type error
getFirstName({}, 'severus', 'snape' ); // ✅ 'severus'
getFirstName({}, 'severus', 'potter' ); // ✅ Type error
getFirstName({}, 'severus', 10 ); // ✅ Type error
It even respects our type constraints, great!
Now, let’s try currying:
type CurriedState< F > =
F extends WithCustomCurrySignature
? F['CurriedSignature']
:
F extends ( state: any, ...args: infer P ) => infer R
? ( ...args: P ) => R
: F;
const curried = {} as CurriedState<GetFirstName>;
curried('harry', 'potter' ); // ✅ 'harry'
curried('harry', 'snape' ); // ✅ Type error
curried('harry', 10 ); // ✅ Type error
curried('severus', 'snape' ); // ✅ 'severus'
curried('severus', 'potter' ); // ✅ Type error
curried('severus', 10 ); // ✅ Type error
Brilliant! And, in case you wondered, CurriedState
can still automatically curry any regular function:
type NumberToString = (state: {}, myNumberArg: number) => string;
const numberToString = {} as NumberToString;
numberToString({}, 15); // ✅ Eeturns a string
numberToString({}, '15'); // ✅ Type error
const curried = {} as CurriedState<NumberToString>;
curried(15); // ✅ Returns a string
curried('15'); // ✅ Type error
There are downsides, of course. Relying on the CurriedSignature
property means there could be a name conflict with another type. Also, it doesn’t make for the most readable code out there.
Still, it works. I never expected to arrive at such as complex solution to such a simple problem!
Leave a Reply