Reputation: 327
I am trying to build an event-processing system for different models using TypeScript and have faced a super-weird error which looks like a bug of TypeScript.
Let's say we have a model ApprovalStep
which is actually a union:
type ApprovalStepCompleted = {
state: 'completed',
};
type ApprovalStepBlocked = {
state: 'blocked',
};
type ApprovalStep = ApprovalStepCompleted | ApprovalStepBlocked;
We want to have specific handlers for every field of any arbitrary model. Here are the definition of event type and of a listeners set:
interface EventDao<Model> {
subject: Model;
}
type Listeners<Model> = Partial<{
[k in keyof Model]: (event: EventDao<Model>) => any;
}>;
This looks pretty straightforward, right? So we can just take any model (like ApprovalSteps
) and build a map of listeners, like so:
const listeners: Listeners<ApprovalStep> = {
state: (ev: EventDao<ApprovalStep>) => {
console.log(ev);
}
}
But when we're trying to call a specific handler, we're getting super-strange error:
const exampleEvent: EventDao<ApprovalStep> = {
subject: {
state: 'completed',
}
}
if (listeners.state) {
// this one raises an error!
listeners.state(exampleEvent);
}
The error says:
Argument of type 'EventDao<ApprovalStep>' is not assignable to parameter of type 'EventDao<ApprovalStepCompleted> & EventDao<ApprovalStepBlocked>'.
Type 'EventDao<ApprovalStep>' is not assignable to type 'EventDao<ApprovalStepCompleted>'.
Type 'ApprovalStep' is not assignable to type 'ApprovalStepCompleted'.
Type 'ApprovalStepBlocked' is not assignable to type 'ApprovalStepCompleted'.
Types of property 'state' are incompatible.
Type '"blocked"' is not assignable to type '"completed"'.(2345)
Which is more strange is that when we manually use ApprovalStep
for the Listeners type (instead of generic Model), it works!
type Listeners<Model> = Partial<{
[k in keyof ApprovalStep]: (event: EventDao<Model>) => any;
}>;
How this can make any difference? Model
should 100% equal to ApprovalStep
in this case!
The whole example can be found at typescript playground
Upvotes: 2
Views: 200
Reputation: 328302
It's not a bug in TypeScript but I don't blame you for being confused. Here's what's going on. When you have:
a mapped type that is homomorphic, meaning it maps over the keys of a type variable like {[K in keyof T]: ...}
, and
the type over whose keys you are mapping (T
above) is a union type, then
the mapping is distributed over that union. Meaning it splits apart the union into its members, does the mapping for each one, and returns the union of those results.
More explicitly, you have type SomeMapping<T> = {[K in keyof T]: ...}
and evaluate SomeMapping<A | B | C>
, you will get the same result as SomeMapping<A> | SomeMapping<B> | SomeMapping<C>
.
I'm not sure if there is some official documentation of this behavior, but you can read microsoft/TypeScript#26063, which mentions this and other things that happen with homomorphic mapped types. (Read the part that starts: "Given a homomorphic mapped type")
In general this behavior yields reasonable results, but sometimes, like now for example, it can do seemingly strange things:
Your definition of Listeners
,
type Listeners<Model> = Partial<{
[k in keyof Model]: (event: EventDao<Model>) => any;
}>;
is a homomorphic mapped type (Partial<T>
itself is homomorphic over T
, and if F<T>
and G<T>
are both homomorphic over T
then so is F<G<T>>
.) And when you evaluate Listeners<ApprovalStep>
you get something like:
const listeners: Partial<{
state: (event: EventDao<ApprovalStepCompleted>) => any;
}> | Partial<{
state: (event: EventDao<ApprovalStepBlocked>) => any;
}>
meaning that listeners
is itself a union type, and so listeners.state
, if it exists, is a union of function types of different arguments, and therefore can only be called with the intersection of its arguments (due to the support introduced in TypeScript 3.3 for calling unions of functions). And so you get that strange error about how you're not calling it with the intersection. This is not what you wanted.
The solution here is probably to prevent the mapping from being homomorphic. This can happen in multiple ways, usually involving some amount of indirection so that the compiler does not interpret the mapping as [K in keyof T]
for a type variable T
. You want to get something in between the in
and the keyof
there.
I'd recommend that you use the Record<K, T>
utility type defined something like type Record<K extends PropertyKey, T> = {[P in K]: T}
. This type is not homomorphic because K
is not keyof
anything directly. Even if you call Record<keyof T, ...>
it does not become homomorphic.
So I'd write:
type Listeners<Model> =
Partial<Record<keyof Model, (event: EventDao<Model>) => any>>;
Now when you define listeners
,
const listeners: Listeners<ApprovalStep> = ...;
its type is
const listeners: Partial<Record<"state", (event: EventDao<ApprovalStep>) => any>>
which is essentially the same as
const listeners: {
state?: ((event: EventDao<ApprovalStep>) => any) | undefined;
}
It's a single object type, not a union, and the state
method takes an argument depending on the full ApprovalStep
union type, not the split-apart-and-unioned-together function from before. Now it should work as you expect:
if (listeners.state) {
listeners.state(exampleEvent); // okay
}
Upvotes: 2