Search code examples
typescriptoverloading

typescript: overload based on a func param count


I want to implement the following:

Make a function F, which accepts a function G as first argument, and:

  • if G has 1 or more params, F accepts that parameters list as second argument
  • if G has 0 params, F doesn't accept anything else

My first naive attempt to achieve it was this:

type F0 = () => any
type F1 = (x: any, ...xs: any[]) => any

function f<F extends F0>(f: F): void
function f<F extends F1>(f: F, xs: Parameters<F>): void

This doesn't quite work, giving following results:

f(() => {}) // ok
f((a: number) => {}, [2]) // ok

f((a: number) => {}, ['a']) 
// Error: Type 'string' is not assignable to type 'number'.
// Good, working as expected

f((a: number) => {})
// Error: Argument of type '(a: number) => void' is not assignable to parameter of type 'F0'
// So it sees this as the first overload. It doesn't ask for a missing second arg.
// Technically, suits my goals, but in a kinda wrong way.

f(() => {}, []) // ok (!)
// Somehow it sees this as valid application of the second overload, 
// regardless of parameters count

The thing is, F0 extends F1 ? true : false gives result of true – that's why last case is recognized as the second overload.

I understand that there is nothing wrong here, it's a correct behaviour for TS.

But is it even possible to solve my task then?


Solution

  • One approach to writing f()'s call signature is the following:

    declare function f<A extends any[]>(
        cb: (...a: A) => any, ...rest: ArgsOrNothing<A>
    ): void;
    
    type ArgsOrNothing<A> =
        [xs: A & [any, ...any]] |
        ([] extends A ? [] : never)
    

    Instead of explicit overloads, it is generic in the rest parameter tuple type A of the callback function, and we then evaluate ArgsOrNothing<A> to determine whether f() should accept another argument or not. Since ArgsOrNothing<A> is a union type, f() behaves like an overloaded function.

    The first possibility: [xs: A & [any, ...any]], which means that f() accepts an additional argument of type A & [any, ...any]]. The type [any, ...any] is a tuple with a rest element, meaning it has to be known to contain at least one element. A value of type A & [any, ...any] is therefore both appropriate as an argument tuple to cb, and has to contain at least one element. That means this possibility is for calling f(cb, xs) with two arguments, where xs is non-empty.

    The other possibility: ([] extends A ? [] : never). This is a conditional type that checks whether the empty argument list [] is assignable to A... in other words, whether cb() is an acceptable call. If so, then f() should be callable with just one argument (by adding [] to the type of ...rest). If not, then f() should not be callable with just one argument (by not adding anything to the type of ...rest; | never doesn't change anything). That means this possibility is for calling f(cb) with one argument, in cases where cb() would be acceptable.

    Let's test it out:

    f(() => { }) // ok
    f((a: number) => { }, [2]) // ok
    f((a: number) => { }, ['a'])  // error
    f((a: number) => { }) // error
    f(() => { }, []) // error
    
    const cb = (a?: number) => { }
    f(cb); // okay
    f(cb, [1]); // okay
    f(cb, []); // error
    
    const cb2 = (...args: string[]) => { }
    f(cb2); // okay
    f(cb2, ["a", "b", "c"]); // okay
    f(cb2, []); // error
    

    These all look good, I think. The standard cases do what you want. For edge cases like when cb() has an optional parameter or when it is variadic, the behavior above is that you should be allowed to call it both ways, but if you want to call the underlying function cb() with no arguments, you must not pass a second argument to f().

    Playground link to code