Michael Kang
Michael Kang

Reputation: 52837

Callback function overloading with generic types in TypeScript

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)?

More Context

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
      }
   }
]

Potential Solution

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 2

Related Questions