Reputation: 52837
Is it possible, in TypeScript, to declare a generic callback type that accepts a variable number of generic type arguments (the number of generic args can be fixed)?
For example:
export interface CustomFn {
<T1>(value1: T1): boolean
<T1,T2>(value1: T1, value2: T2): boolean
<T1,T2,T3>(value1: T1, value2: T2, value3: T3): boolean
}
So that I can do things like:
const t: CustomFn = (a: string) => false
const t2: CustomFn = (a: number, b: string) => false
const t3: CustomFn = (a: boolean, b: string, c: number) => false
The above is almost what I want, except it doesn't quite work.
The first line works.
The second and third line complains with a compiler error:
Type '(a: number, b: string) => any' is not assignable to type 'CustomFn'
What is the correct way to declare a callback type that accepts variable generic args (the number of args can be fixed)?
This is part of a broader solution. Ideally, I want to do something like this:
export interface ValidationRule {
name: string
validator: CustomFn
}
const validationRules: CustomFn[] = [
{
name: 'required',
validator: (s: string) => {
return s != null
}
},
{
name: 'greaterThan',
validator: (value: number, max: number) => {
return value > max
}
}
]
Maintainability is important. I would be ok if the types had to be unioned like this:
export declare type GenericPredicate =
CustomFn<T1> |
CustomFn<T1,T2> |
CustomFn<T1,T2,T3>
export interface ValidationRule {
name: string
validator: GenericPredicate
}
const validationRules: CustomFn[] = [
{
name: 'required',
validator: (s: string) => {
return s != null
}
},
{
name: 'greaterThan',
validator: (value: number, max: number) => {
return value > max
}
}
]
Upvotes: 1
Views: 479
Reputation: 327819
If you were just looking for a type, it would probably be:
type CustomFn = (...args: any[]) => boolean;
or maybe the safer
type CustomFn = (...args: never[]) => boolean;
which will accept any function that returns a boolean
value.
But there's a catch: if you annotate a variable as this type, the compiler will unfortunately forget anything more specific than that the function returns a boolean
. Specific information about the number and types of parameters will be lost.
The version with any[]
will let you call the function with the right parameters:
type CustomFn = (...args: any[]) => boolean;
const f: CustomFn = (s: string) => s.toUpperCase() === s.toLowerCase();
f("okay");
But it will also let you call the function with the wrong parameters:
try {
f(123); // wait, no compiler error?
} catch (e) {
console.log(e) // RUNTIME ERROR 💥 s.toUppercase is not a function
}
On the other hand, the version with never[]
is so safe that it doesn't even let you call the function with the right parameters:
type CustomFn = (...args: never[]) => boolean;
const f: CustomFn = (s: string) => s.toUpperCase() === s.toLowerCase();
f("okay"); // error, this is not allowed!
The problem here isn't so much the CustomFn
type, it's with the values of that type.
My suggestion here would be to replace type annotations with the satisfies
operator. If you write const vbl: Type = value
then vbl
will usually only know about Type
and not a possibly narrower typeof value
. On the other hand, if you write const vbl = value satisfies Type
, then the compiler will make sure that value
satisfies Type
without widening it. And vbl
's type with be typeof value
, which might be more specific than Type
.
For the above example code that becomes:
const f = (
(s: string) => s.toUpperCase() === s.toLowerCase()
) satisfies CustomFn; // okay
f("okay"); // okay
f(123); // compiler error
See how f
is verified as satisfying CustomFn
but the compiler remembers that it is of type (s: string) => boolean
so it will allow you to call f("okay")
and complain about f(123)
.
For your example code, we can do something similar:
interface ValidationRule {
name: string
validator: CustomFn
}
const validationRules = [
{
name: 'required',
validator: (s?: string) => {
return s != null
}
},
{
name: 'greaterThan',
validator: (value: number, max: number) => {
return value > max
}
},
// uncomment the following line to see mistake
// { name: 'mistake', validator: (a: string, b: number) => "oopsie" }
] as const satisfies readonly ValidationRule[];
Here we use a const
assertion to ask the compiler to keep track of the exact literal types of the name
properties of the array elements. And instead of widening validationRules
to ValidationRule[]
which would forget everything more specific about it, we just use satisfies
to make sure it would work.
If you made a mistake with validationRules
, you'd get an error on satisfies
:
const badValidationRules = [
{
name: 'required',
validator: (s?: string) => {
return s != null
}
},
{
name: 'greaterThan',
validator: (value: number, max: number) => {
return value > max
}
},
{ name: 'mistake', validator: (a: string, b: number) => "oopsie" }
] as const satisfies readonly ValidationRule[]; // error!
// ----------------> ~~~~~~~~~~~~~~~~~~~~~~~~~
// Type '{ name: "mistake"; validator: (a: string, b: number) => string; }'
// is not assignable to type 'ValidationRule'.
// string is not assignable to boolean
And since validationRules
has not been widened, the compiler can type check more accurately:
const someRule = validationRules[Math.floor(Math.random() * validationRules.length)];
/* const someRule: {
readonly name: "required";
readonly validator: (s?: string) => boolean;
} | {
readonly name: "greaterThan";
readonly validator: (value: number, max: number) => boolean;
} */
if (someRule.name === "greaterThan") {
someRule.validator(1, 2); // okay
}
The compiler knows that someRule
is a discriminated union where the name
property can be used to determine the type of validator
.
Upvotes: 2