6

Suppose the following sample code:

type Action = "GET" | "POST" | "PUT";
type Handler<A extends Action> = (action: A) => void;

const handlers: Partial<Record<Action, Handler<Action>>> = { };

function register<A extends Action>(action: A, handler: Handler<A>) {
    /*
    Error:
    Type 'Handler<A>' is not assignable to type 'Partial<Record<Action, Handler<Action>>>[A]'.
        Type 'Handler<A>' is not assignable to type 'Handler<Action>'.
            Type 'Action' is not assignable to type 'A'.
            'Action' is assignable to the constraint of type 'A', but 'A' could be instantiated with a different subtype of constraint 'Action'.
                Type '"GET"' is not assignable to type 'A'.
                '"GET"' is assignable to the constraint of type 'A', but 'A' could be instantiated with a different subtype of constraint 'Action'.
     */
    handlers[action] = handler;
}

To my understanding, this error above occurs because A allows for types larger than Action (e.g. it could be Action | "DELETE"), but my handlers record only allows for exactly the Action union type. There are ways I can sort of work around this:

  • Cast down handler internally. This quiets down the compiler without actually solving the problem, as the user can still pass a larger type into register. Plus, casts are never ideal :)
  • Make the function concrete, register(action: Action, handler: Handler<Action>). This now means that action and handler don't have to agree in their types, which can lead to runtime errors.

As neither of these workarounds fully solve the issue, is there a way for me to enforce that action and handler both use the same A, while also not allowing for A to be any larger than Action?


Edit:

I've actually found an even smaller minimum repro that gives the same error:

function foobar<T extends "foo" | "bar">(func: (arg: T) => void): (arg: "foo" | "bar") => void {
    return func;
}

where the return statement gives the same error as above. This reveals the actual issue: T can actually be smaller than the union, so you might end up passing in a function that can handle less cases than what's expected.

y2bd
  • 5,783
  • 1
  • 22
  • 25
  • 2
    A good solution won't exist unless [this](https://github.com/microsoft/TypeScript/issues/14520) or [this](https://github.com/microsoft/TypeScript/issues/27808) is implemented so just cast it – Austaras Jun 19 '19 at 22:06
  • 1
    `A extends Action` doesn't allow `A` to be anything "larger" than `Action`. It cannot be `Action | "DELETE". `A extends B` means that `A` is a *subtype* of `B`, or that if you have a value of type `A` it must also be of type `B`. A value of type `Action | "DELETE"` is not required to be a value of type `Action`, since it might be `"DELETE"`. – jcalz Jun 20 '19 at 01:20
  • You probably want `handlers` to be defined as a mapped type that correlates the key to the value: `const handlers: {[K in Action]?: Handler} = {};` – jcalz Jun 20 '19 at 01:20
  • 1
    But the issue will remain that the compiler can't see that `handlers[action]` and `handler` are compatible, for the reasons @Austaras mentioned. There's not great support for [correlated](https://github.com/microsoft/TypeScript/issues/30581) types in TypeScript and this has only gotten worse since TS3.5 has gotten more [strict](https://github.com/microsoft/TypeScript/pull/30769) with indexed access. – jcalz Jun 20 '19 at 01:24
  • Look at [this answer](https://stackoverflow.com/a/59363875/8233039) it tries to completely interpret what this kind of message want to mean. Let me know if it solves your problem. – Flavio Vilante Dec 18 '19 at 21:57

2 Answers2

3

A good solution won't exist unless this or this is implemented so just cast it.

A extends Action doesn't allow A to be anything "larger" than Action. It cannot be Action | "DELETE". A extends B means that A is a subtype of B, or that if you have a value of type A it must also be of type B. A value of type Action | "DELETE" is not required to be a value of type Action, since it might be "DELETE".

You probably want handlers to be defined as a mapped type that correlates the key to the value: const handlers: {[K in Action]?: Handler<K>} = {};

But the issue will remain that the compiler can't see that handlers[action] and handler are compatible, for the reasons mentioned in the first sentence above. There's not great support for correlated types in TypeScript and this has only gotten worse since TS3.5 has gotten more strict with indexed access.

ChrisW
  • 54,973
  • 13
  • 116
  • 224
  • 1
    This is a copy-and-paste of upvoted comments, which were posted under the question -- which I repost here as an answer, in case those comments are deleted. – ChrisW Jun 20 '19 at 21:16
0

When you extend a type in TypeScript you are extending the constraints on the type (which means you are reducing the range of values the type can contain).

If you want to be able to "extend" the range of values (in the way you're thinking about it), you need to start with a wide scope for your initial type definition so you can narrow that initial broad scope to the subset of values you want. Try this:

type BroadAction = "GET" | "POST" | "PUT" | string;
function foo<T extends BroadAction>() {
  // do something
}
foo<"GET" | "POST" | "PUT" | "INFO" | "DELETE">();

The above code lets you "extend" the allowed list of actions in the way you're thinking about, by framing it in the context of extending the constraints on the type from allowing all strings to just allowing some strings.

The downside of the nature of extend in TypeScript is that the following lines are also valid.

type BroadAction = "GET" | "POST" | "PUT" | string;
function foo<T extend BroadAction>() {
  // do something
}
foo<"GET">();

type Action = "GET" | "POST" | "PUT";
function bar<T extends Action>() {
  // do something
}
bar<"GET">();

Don Alvarez
  • 2,017
  • 2
  • 12
  • 12