Home > Net >  How to force generic argument to be only one type of an union
How to force generic argument to be only one type of an union

Time:02-04

I have following code (simplified):

const todoApi = {
    add(name: string) {},
    edit(id: number, name: string) {},
}

type TodoApi = typeof todoApi;
type TodoCommandName = keyof TodoApi

function createOptimisticUpdate<TCommand extends TodoApi[TodoCommandName]>(
    command: TCommand,
    localUpdate: (...p: Parameters<TCommand>) => void
) {
    return (...p: Parameters<TCommand>) => {
        localUpdate(...p);
        command(...p)   // should work but causes Type Error: A spread argument must either have a tuple type or be passed to a rest parameter.(2556)
        command(1)  // not expected to work but the error reveals expected params: (parameter) command: (arg0: never, name: string) => void, Expected 2 arguments, but got 1.(2554)
        command(...p as [never, string]) // workaround :(
    };
}

// from outside, when used with one command, everything works as expected
createOptimisticUpdate(todoApi.add, name => {})("name")
createOptimisticUpdate(todoApi.edit, (id, newName) => {})(1, "newName")
createOptimisticUpdate(todoApi.edit, (name) => {/* we have declared only one parameter with wrong name, but type is correctly inferred as number */})("newName")    // error: Expected 2 arguments, but got 1.

// but when we try to use more commands at once (not wanted), things are getting strange
let addOrEdit = Math.random() ? todoApi.add : todoApi.edit;
createOptimisticUpdate(addOrEdit, () =>{})("a")  // not expected: both signatures are valid
/*
type: function createOptimisticUpdate<((name: string) => void) | ((id: number, name: string) => void)>(command: ((name: string) => void) | ((id: number, name: string) => void), localUpdate: (...p: [name: string] | [id: number, name: string]) => void): (...p: [name: string] | [id: number, name: string]) => void
 */

TS Playground link

When used with one command, everything works as expected, but the problem seems to be the usage is not restricted to that. Is there any way to force this?

CodePudding user response:

There is no syntactic way to forbid a union if the constraint is a union, such as in your case.

We do have some workarounds. One would be to intersect TCommand with an intersection of itself. If TCommand is a union, the intersection will create an incompatibility, while if TCommand is a simple type (or e compatible union) there will not be any incompatibility:

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
function createOptimisticUpdate<TCommand extends TodoApi[TodoCommandName]>(
    command: TCommand & UnionToIntersection<TCommand>,
    localUpdate: (...p: Parameters<TCommand>) => void
) {
    return (...p: Parameters<TCommand>) => {
        localUpdate(...p);
        (command as any)(...p)
    };
}
let addOrEdit = Math.random() ? todoApi.add : todoApi.edit;
createOptimisticUpdate(addOrEdit, () =>{})("a") // error 

Playground Link

The error isn't great, you could change it if you use a string literal type to create the incompatibility. The error is a bit better as at least it points to the issue:

type ErrorType<TExpected, TActual, TError> = TExpected extends TActual ? unknown:  TError;
function createOptimisticUpdate<TCommand extends TodoApi[TodoCommandName]>(
    command: TCommand & ErrorType<TCommand, UnionToIntersection<TCommand>, "Unions are not supported">
    localUpdate: (...p: Parameters<TCommand>) => void
) {
   //....
}
let addOrEdit = Math.random() ? todoApi.add : todoApi.edit;
createOptimisticUpdate(addOrEdit, () =>{})("a") // error 

Playground Link

Another option is to use a version of Parameters that is not distributive (The predefined one is distributive). This will prevent the creation of a callable function if a union is passed in:

type ParametersNonDistributive<T> = [T] extends [(...a: infer P) => any]? P: never;
function createOptimisticUpdate<TCommand extends TodoApi[TodoCommandName]>(
    command: TCommand,
    localUpdate: (...p: ParametersNonDistributive<TCommand>) => void
) {
    return (...p: ParametersNonDistributive<TCommand>) => {
        localUpdate(...p);
        (command as any)(...p)
    };
}
let addOrEdit = Math.random() ? todoApi.add : todoApi.edit;
createOptimisticUpdate(addOrEdit, () =>{})("a") // error 
createOptimisticUpdate(todoApi.add, () =>{})("a") // ok

Playground Link

  •  Tags:  
  • Related