Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Expecting an error but code compiles fine

Tags:

typescript

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

like image 830
areller Avatar asked Oct 14 '22 19:10

areller


1 Answers

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.

like image 92
captain-yossarian Avatar answered Oct 23 '22 10:10

captain-yossarian