Home > Software design >  The intersection 'xxx & xxx' was reduced to 'never' because property 'xxx&#
The intersection 'xxx & xxx' was reduced to 'never' because property 'xxx&#

Time:05-21

I got an error while using ts generics, here is the simple code:

Typescript Playground

On the last line, ts reports the following error:

error TS2345: Argument of type 'Task<"build"> | Task<"repair">' is not assignable to parameter of type 'never'.
  The intersection 'Task<"build"> & Task<"repair">' was reduced to 'never' because property 'type' has conflicting types in some constituents.
   
44 action_map[task.type].execute(task);

I try to use switch to avoid errors:

function execute<T extends TaskType>(task: Task<T>) {
    switch (task.type) {
        case "build":
            // now type of `task` should be `Task<"build">`
            BuildAction.execute(task);
            break;
        case "repair":
            // now type of `task` should be `Task<"repair">`
            RepairAction.execute(task);
            break;
        default:
            // now type of `task` should be `Task<never>`
            console.log("Error");
    }
}

But it even worse:

error TS2345: Argument of type 'Task<T>' is not assignable to parameter of type 'Task<"build">'.
  Type 'T' is not assignable to type '"build"'.
    Type 'keyof TMap' is not assignable to type '"build"'.
      Type '"repair"' is not assignable to type '"build"'.

50    BuildAction.execute(task);

I noticed that vscode's type hint for task is always Task<T> instead of what I expected.

So, what should I do?

CodePudding user response:

The problem you are facing is that the compiler doesn't have great direct support for dealing with correlated union types, as described in microsoft/TypeScript#30581.

Your task is of a union type:

declare var task: Task<"build"> | Task<"repair">;

So task is either a Task<"build"> or a Task<"repair">. And action_map[task.type].execute is therefore also of a union type:

const execute = action_map[task.type].execute;
// const execute: ((task: Task<"build">) => void) | ((task: Task<"repair">) => void)

Which means execute will either accept a Task<"build"> or it will accept a Task<"repair">, but it will not accept both. Let's imagine we had another task:

declare var otherTask: Task<"build"> | Task<"repair">;

Clearly it would not be safe to use execute to execute that task:

execute(otherTask); // error!

After all, otherTask might be a Task<"build"> while execute might be a ((task: Task<"repair">) => void). Well, unfortunately, the types are all the compiler can use to determine whether something is safe or not. Since execute(otherTask) is unsafe, and task is of the same union type as otherTask, then execute(task) is seen as unsafe for the same reason:

execute(task); // same error!

Of course we know that execute will accept task because they are correlated to each other. But that's because we're tracking the identity of the task variable and not just the type. The compiler can't tell the difference between execute(otherTask) and execute(task). So it worries that they might not be correlated.

The reason why the error mentions the intersection is because the only safe way for a union of functions to be called is to call it with an intersection of the parameters. If execute accepts a Task<"build"> or a Task<"repair"> but we don't know which, the only safe thing to pass it would be something that's both a Task<"build"> and a Task<"repair">... hence a Task<"build"> & Task<"repair">. And of course such a thing is impossible; they have conflicting properties, so you get that message about the never type.

So that's why you're seeing an error.


As for fixing it, TypeScript 4.6 introduced some improvements specifically to address microsoft/TypeScript#30581; you can read about it in microsoft/TypeScript#47109.

The recommended approach is to make a generic function. If K extends TaskType is a generic type parameter, the compiler will allow you to call a function of type (task: Task<K>)=>void with a parameter of type Task<K>. And the improvements in TypeScript 4.6 mean that the compiler will see action_map[task.type] as being of type (task: Task<K>)=>void:

type SomeTask<T extends TaskType = TaskType> = { [K in T]: Task<K> }[T];

function genericExecute<K extends TaskType>(task: SomeTask<K>) {
    action_map[task.type].execute(task); // okay
}

The SomeTask<T> type is not required by the compiler, but it shows how you can get a union of task types easily. If TaskType contained more union members, SomeTask would also gain those members. And the body of genericExecute() is seen as safe.

And luckily you can call genericExecute(task) with no problem:

genericExecute(task); // okay

So that's the recommended approach to such correlated union types.

Playground link to code

  • Related