1

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?

Svatopluk Ledl
  • 316
  • 3
  • 6
  • There is no way to forbid a union. One solution: `command: TCommand & UnionToIntersection,` with type from here https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type – Titian Cernicova-Dragomir Feb 03 '22 at 08:21

1 Answers1

2

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

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Thank you, sir. I've hoped that restricting the input possibilities would solve the need of the cast inside the function, but closer understanding the problem at least allowed me to change `p as [never, never, never, never, never, never, never, never, never, never, never]` (yes, eleven) to `p as ParametersNonDistributive>`. – Svatopluk Ledl Feb 03 '22 at 10:13
  • Do you have any insight why calling the generic command requires to match all possible inputs (`TodoApi[TodoCommandName]`) and not just `TCommand`? – Svatopluk Ledl Feb 03 '22 at 10:14
  • 1
    @SvatoplukLedl This is a known limitation in the ability of TS to correlate things. I would just cans the `command` to `any` when calling – Titian Cernicova-Dragomir Feb 03 '22 at 10:20
  • It has been simplified a lot - all functions actually return the same kind of promise, so it is useful not to discard whole type of `command`. Thank you again for clarification. – Svatopluk Ledl Feb 03 '22 at 10:25