Search code examples
typescriptcombinationsunion-types

How to declare a type as all possible combination of a union type?


TL,DR;

What I need here is to somehow declare a union of all possible combination of a given union.

type Combinations<SomeUnion, T extends any[]> = /* Some magic */
//                          ^^^^^^^^^^^^^^^
//                       this type argument provides the information
//                       about what is the length of expected combination.

// then


Combinations<string | number, ['x', 'y']> =
    [string, string] |
    [string, number] |
    [number, string] |
    [number, number] 

Combinations<string | number | boolean, ['x', 'y']> =
    [string, string]  |
    [string, number]  |
    [string, boolean] |
    [number, string]  |
    [number, number]  |
    [number, boolean] |
    [boolean, string] |
    [boolean, number] |
    [boolean, boolean] 

Combinations<string | number, ['x', 'y', 'z']> =
    [string, string, string] |
    [string, string, number] |
    [string, number, string] |
    [string, number, number] |
    [number, string, string] |
    [number, string, number] |
    [number, number, string] |
    [number, number, number]

Detail

I want to define a method decorator which can type-safely guarantees the number of arguments of the method being decorated is exactly same as the number of arguments passed in to that decorator.



type FixedLengthFunction<T extends any[]> = (...args: { [k in keyof T]: any }) => void

function myDecorator<T extends any[]>(...args: T) {
    return <K extends string>(
        target: { [k in K]: FixedLengthFunction<T> },
        methodName: K,
        desc: any
    ) => {}
}


// Note: WAI => Works as intented
class Foo {
   @myDecorator()
   a() {}
   // expected to be correct,
   // and actually passes the type system.
   // WAI

   @myDecorator()
   b(x: number) {}
   // expected to be incorrect since 'b' has one more argument,
   // and actually catched by the type system.
   // WAI

   @myDecorator('m')
   c(x: number) {}
   // expected to be correct,
   // and actually passes the type system.
   // WAI

   @myDecorator('m')
   d() {}
   // expected to be incorrect since 'd' has one less argument,
   // but still passes the type system.
   // not WAI
}

Same applies to all scenarios where the decorated method has less arguments then the decorator call.

The underlying reason is: (a: SomeType) => void is compatible with (a: any, b: any) => void since any can be undefined.

Then I modified FixedLengthFunction into

type Defined = string | number | boolean | symbol | object
type FixedLengthFunction<T extends any[]> =
    (...args: { [k in keyof T]: Defined }) => void
//                              ^^^^^^^
//                      changes: any -> Defined

However, it becomes "false positive" and complains that

@myDecorator('m')
c(x: number) {}

is incorrect.

This time the reason is (x: number) => void is not compatible with (arg_0: Defined) => void. number is narrowed version of Defined and narrowing the type of parameters voilates the LSP, hence the error.

The problem is: FixedLengthFunction<['m', 'n']> is resolved to (...args: [Defined, Defined]) => void which is further resolved as (arg_0: Defined, arg_1: Defined) => void.

While what I actually want is:

(...args: 
    [number, number] |
    [string, number] |
    [boolean, string] 
    /* ...and all other possible combinations of length 2 */
) => void

So, what I need here is the magical type Combinations at the top of this post.


Solution

  • Generating such a union is a bad idea. It will grow out of control and create perf problems during compilation. You probably can do it with recursive type aliases but it is highly discouraged (ie. you can trick the compiler into doing it but it might not work in the future)

    That being said I think the problem you identified is wrong. You say that the function with fewer parameter is assignable to the function with more parameter because of any. It is not. In general typescript allow a function with fewer parameters to be assigned where a function with more parameters is expected. The function implementation will ignore the extra parameters and no harm will come of it:

    let fn: (a: number) => void = function () { console.log("Don't care about your args!"); }
    
    fn(1)// 1 is ignored but that is ok 
    

    We can enforce a strict equality of number of parameter based on the fact that tuples have a length property and the fact that we can infer the actual type of the class and extract the actual parameters from the type:

    type FixedLengthFunction<T extends any[]> = (...args: { [k in keyof T]: any }) => void
    
    type ErrorIfDifferentLength<TMethod, TExpected extends any[]> = 
        TMethod extends (...a: infer TParams) => any ? 
        TParams['length'] extends TExpected['length'] ? {}: { "!Error": "Number of parameters differ:", actual:  TParams['length'], expected: TExpected['length'] } : {}
    
    function myDecorator<T extends any[]>(...a: T) {
        return <K extends string, TClass extends Record<K, FixedLengthFunction<T>>>(target: TClass & ErrorIfDifferentLength<TClass[K], T>, key: K): void => {
    
        }
    }
    
    // Note: WAI => Works as intented
    class Foo {
        @myDecorator()
        a() {}
        // expected to be correct,
        // and actually passes the type system.
        // WAI
    
        @myDecorator()
        b(x: number) {}
        // expected to be incorrect since 'b' has one more argument,
        // and actually catched by the type system.
        // WAI
    
        @myDecorator('m')
        c(x: number) {}
        // expected to be correct,
        // and actually passes the type system.
        // WAI
    
        @myDecorator('m')
        d() {}
        // Argument of type 'Foo' is not assignable to parameter of type 'Foo & { "!Error": "Number of parameters differ:"; method: "d"; actual: 0; expected: 1; }'.
        // WAI
    }