Given this code,
interface TaskStartedEvent {
type: "started",
task: string
}
interface TaskLogEvent {
type: "log",
task: string,
message: string
}
interface TaskFailedEvent {
type: "failed",
task: string,
error?: string
}
interface FreeLog {
message: string | Error,
meta?: unknown
}
interface UndefinedTask {
task?: undefined
}
type TaskEvent = TaskStartedEvent | TaskLogEvent | TaskFailedEvent;
type RuntimeEvent = (FreeLog & UndefinedTask) | TaskEvent;
function foo(ev: RuntimeEvent) {
console.log(ev);
}
foo({ message: "bar", type: "log" });
Why isn't the Typescript compiler failing here?
I pass a type
field so it cannot be a (FreeLog & UndefinedTask)
type, but I don't pass a task
field so it cannot be a TaskEvent
as well.
This code compiles with no errors (typescriptlang.org link).
The problem is in this line: (FreeLog & UndefinedTask)
.
Above intersection produces this type:
type Debug<T> = {
[Prop in keyof T]: T[Prop]
}
// type Result = {
// message: string | Error;
// meta?: unknown;
// task?: undefined;
// }
type Result = Debug<FreeLog & UndefinedTask>
We ended up with one required property: message
. Let's test it:
function foo(ev: RuntimeEvent) {
console.log(ev);
}
foo({ message: '2' });
But why it also allows type
? { message: string, type: 'log' }
is not a type of any union.
type Check<T> = T extends RuntimeEvent ? true : false
// true
type Result = Check<{ message: 'string', type: 'log' }>
{ message: 'string', type: 'log' }
extends RuntimeEvent
because FreeLog & UndefinedTask
is a part of it and it expects minimum one property message
to meet the minimum requirements.
We know why it is allowed, but what about type
? Why it is allowed to use only log
value? Because when you started typing type
property, TS started to check your arguments. It appears that only union with log
has both message
and type
properties.
You may or may not provide task
property. It is up to you. TS does not complain because technically, your argument meet requirements.
To make it work in a way you expect, you can use StrictUnion
helper:
// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> =
T extends any
? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>
Full example:
type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> =
T extends any
? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>
interface TaskStartedEvent {
type: "started",
task: string
}
interface TaskLogEvent {
type: "log",
task: string,
message: string
}
interface TaskFailedEvent {
type: "failed",
task: string,
error?: string
}
interface FreeLog {
message: string | Error,
meta?: unknown
}
interface UndefinedTask {
task?: undefined
}
type TaskEvent = TaskStartedEvent | TaskLogEvent | TaskFailedEvent;
type RuntimeEvent = StrictUnion<(FreeLog & UndefinedTask) | TaskEvent>;
function foo(ev: RuntimeEvent) {
console.log(ev);
}
foo({ message: 'string', type: 'log' }); // expected error
Playground
You can find more interesting examples in my blog
Summary
FreeLog & UndefinedTask
does not expect you to provide the task
property. At least task
is not required whereas TaskEvent
requires task
. SO, you ended up in a situation with two element in a union. One element requires task
and another one not.
.. inconsistent with how a discriminated union would behave ...
Please keep in mind, your union is not discriminated. Make task
required prop in UndefinedTask
. It will help.
Discriminated union
If you wan to use discriminated union, also tagged unions, you should create a type
for each element of a union. The type
property should be different for each element in the union.
See example:
interface TaskStartedEvent {
type: "started",
task: string
}
interface TaskLogEvent {
type: "log",
task: string,
message: string
}
interface TaskFailedEvent {
type: "failed",
task: string,
error?: string
}
interface FreeLog {
message: string | Error,
meta?: unknown
}
interface UndefinedTask {
task: undefined
}
type UndefinedEvent = (FreeLog & UndefinedTask) & {
type: 'undefined'
}
type TaskEvent = TaskStartedEvent | TaskLogEvent | TaskFailedEvent;
type RuntimeEvent = UndefinedEvent | TaskEvent;
function foo(ev: RuntimeEvent) {
console.log(ev);
}
foo({ message: "bar", type: "log" }); // error
Also, please take a look on example from the docs:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; x: number }
| { kind: "triangle"; x: number; y: number };
Property kind
serves as a tag for each element in the union.
For instance, F# also uses discriminated unions:
type Shape =
| Rectangle of width : float * length : float
| Circle of radius : float
| Prism of width : float * float * height : float
As you might have noticed, Rectangle
, Circle
and Prism
are just tags.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With