5

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).

Kit
  • 20,354
  • 4
  • 60
  • 103
areller
  • 4,800
  • 9
  • 29
  • 57
  • 1
    Here's a [minimal example](https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgCpwM4GsAyB7AcwFEA3CcZAbwChk7kwBPABwgC5kAiAG0M4G5a9MJiwcMYKKAKD6yALYQMGOAXbIJUkDOoBfatVCRYiFADEoECPgJUhdRctXrN05AB9kRKFDxRB+tRMrMgASgCu4MCKpORgyAC8yBZWNh5oojax4ILUMJEIYMB4IMgweHgAFBAkHBFRMWTgAJR2cgglGHjcEAB0vATVJM38cnoG5VWUCkoqahycAEZwUJwANAws6jx8yLojQA). It appears that the `Object literal may only specify known properties` error will only be raised when specifying a field that isn't present on _any_ of the types in the union. Not sure if this is intended behaviour or not. – superhawk610 Sep 22 '21 at 04:35
  • 1
    It's also worth noting that while this _looks_ like a discriminated union, it's not since `FreeLog` doesn't specify a constant string type. – superhawk610 Sep 22 '21 at 04:35
  • @superhawk610 yeah, I know it's not a proper discriminated union. I still expect it to behave correctly. It looks like a bug to me, but I'm not sure. – areller Sep 22 '21 at 04:42
  • What error do you expect and where? – captain-yossarian from Ukraine Sep 22 '21 at 05:52
  • @captain-yossarian expecting either something like `unexpected field type` or `expected field task`, when I pass the `{ message: "bar", type: "log" }` to `foo` – areller Sep 22 '21 at 06:21

1 Answers1

3

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.

  • That strict union trick works, but I guess I still wonder about `You may or may not provide task property. It is up to you. TS does not complain because technically, your argument meet requirements.`. Why is it the case with regular union? After all, if I provide `type: "log"`, the compiler should expect that I provide all of the fields in `TaskLogEvent`, no? – areller Sep 22 '21 at 17:21
  • It looks like, because both `FreeLog & UndefinedTask` and `TaskLogEvent` expect me to provide a `task`, it's enough to only satisfy one of those (e.g. only satisfy the `task` requirement set by `FreeLog & UndefinedTask`), but that's still a bit unintuitive for me because it seems inconsistent with how a discriminated union would behave (if I make the `task` inside `UndefinedTask` an actual string, it will work as expected) – areller Sep 22 '21 at 17:26
  • 1
    So the only way to do a strict union (without using that helper) is to have a fully discriminated union (unlike what I did). It still looks like something I'd expect the TS compiler to handle, but I guess it makes sense. – areller Sep 24 '21 at 02:09
  • 1
    It is better to stick with discriminated unions. – captain-yossarian from Ukraine Sep 24 '21 at 06:41