Typescript: Discriminated unions constraints get ignored when used with Omit

Created on 25 Mar 2019  Â·  4Comments  Â·  Source: microsoft/TypeScript

To be honest I have no clue how to name this issue properly.

TypeScript Version: 3.4.0-dev.201xxxxx


Search Terms:

Code

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

type ContextTitle =
  { title?: string } |
  { title: string, shortcut?: string }

type UselessStuff = {
  name: string,
  age: number
}

type DeleteStuff = Omit<UselessStuff, 'name'>

type T = ContextTitle & DeleteStuff

const a: T = {
  age: 10,
  shortcut: 'hey'
} 

Expected behavior:

Get an error because I've defined shortcut, but not the required property title

Actual behavior:

Typescript does not report any error. Apparently it correctly detects the problem when using UselessStuff instead of the "Omitted" version.

Playground Link: http://www.typescriptlang.org/play/index.html#src=type%20Omit%3CT%2C%20K%20extends%20keyof%20T%3E%20%3D%20Pick%3CT%2C%20Exclude%3Ckeyof%20T%2C%20K%3E%3E%0A%0Atype%20ContextTitle%20%3D%0A%20%20%7B%20title%3F%3A%20string%20%7D%20%7C%0A%20%20%7B%20title%3A%20string%2C%20shortcut%3F%3A%20string%20%7D%0A%0Atype%20UselessStuff%20%3D%20%7B%0A%20%20name%3A%20string%2C%0A%20%20age%3A%20number%0A%7D%0A%0Atype%20DeleteStuff%20%3D%20Omit%3CUselessStuff%2C%20%27name%27%3E%0A%0A%0Atype%20T%20%3D%20ContextTitle%20%26%20DeleteStuff%0A%0Aconst%20a%3A%20T%20%3D%20%7B%0A%20%20age%3A%2010%2C%0A%20%20shortcut%3A%20%27hey%27%0A%7D%20%0A

Question

Most helpful comment

A smaller repro:

type ContextTitle =
  { title?: string, age: number } |
  { title: string, age: number, shortcut?: string }

const a: ContextTitle = {
  age: 10,
  shortcut: 'hey'
} 

I think this is not a bug just a side effect of how unions and excess property checks (EPC) work. EPC for unions will not raise an error if the key is present on any member of the union, so both age and shortcut are ok in the object literal as far as EPC is concerned.

Secondly, when the object literal is checked for compatibility with the union, the assignment is valid if the object literal is valid for any member of the union. But you might say "That object literal is not a valid value for any member of the union". Not true, excluding EPC, { age: 10, shortcut: 'hey'} is compatible with { title?: string, age: number }. It does not have title but that is optional anyway, it has age as required, and has an extra property that, as we stated earlier EPC will not complain about.

The solution is to add a member make the union members incompatible:

type ContextTitle = 
  { title?: string, age: number, shortcut?: undefined  } |
  { title: string, age: number, shortcut?: string }

const a: ContextTitle = { age: 10, shortcut: 'hey'}  //err

Or a more general approach, using StrictUnion (also found here although that exact version might have some issued in 3.4):

type UnionKeys<T> = T extends any ? keyof T : never;
type StrictUnionHelper<T, TAllKeys extends PropertyKey> = T extends any ? T & Partial<Record<Exclude<TAllKeys, keyof T>, undefined>> : never;
type StrictUnion<T> = StrictUnionHelper<T, UnionKeys<T>>

type ContextTitle = StrictUnion<
  { title?: string, age: number } |
  { title: string, age: number, shortcut?: string }>

const a: ContextTitle = { age: 10, shortcut: 'hey'}  //err

All 4 comments

A smaller repro:

type ContextTitle =
  { title?: string, age: number } |
  { title: string, age: number, shortcut?: string }

const a: ContextTitle = {
  age: 10,
  shortcut: 'hey'
} 

I think this is not a bug just a side effect of how unions and excess property checks (EPC) work. EPC for unions will not raise an error if the key is present on any member of the union, so both age and shortcut are ok in the object literal as far as EPC is concerned.

Secondly, when the object literal is checked for compatibility with the union, the assignment is valid if the object literal is valid for any member of the union. But you might say "That object literal is not a valid value for any member of the union". Not true, excluding EPC, { age: 10, shortcut: 'hey'} is compatible with { title?: string, age: number }. It does not have title but that is optional anyway, it has age as required, and has an extra property that, as we stated earlier EPC will not complain about.

The solution is to add a member make the union members incompatible:

type ContextTitle = 
  { title?: string, age: number, shortcut?: undefined  } |
  { title: string, age: number, shortcut?: string }

const a: ContextTitle = { age: 10, shortcut: 'hey'}  //err

Or a more general approach, using StrictUnion (also found here although that exact version might have some issued in 3.4):

type UnionKeys<T> = T extends any ? keyof T : never;
type StrictUnionHelper<T, TAllKeys extends PropertyKey> = T extends any ? T & Partial<Record<Exclude<TAllKeys, keyof T>, undefined>> : never;
type StrictUnion<T> = StrictUnionHelper<T, UnionKeys<T>>

type ContextTitle = StrictUnion<
  { title?: string, age: number } |
  { title: string, age: number, shortcut?: string }>

const a: ContextTitle = { age: 10, shortcut: 'hey'}  //err

Thanks — that kind of explains the issues; I figured out with some external help that another way to make this happen is

type ContextTitle =
  { title?: string, shortcut: never } |
  { title: string, shortcut?: string }

my few cents, a better practice is to use a dedicated discriminator property

using booleans

type ContextTitle =
|  { isThat: true, title?: string}
|  { isThat: false, title: string, shortcut?: string }

or anything else

type ContextTitle =
|  { kind: 'this', title?: string }
|  { kind: 'that', title: string, shortcut?: string }

I tend to agree, but unfortunately this would be kind of a breaking change that I can't really do right now.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

wmaurer picture wmaurer  Â·  3Comments

zhuravlikjb picture zhuravlikjb  Â·  3Comments

MartynasZilinskas picture MartynasZilinskas  Â·  3Comments

kyasbal-1994 picture kyasbal-1994  Â·  3Comments

jbondc picture jbondc  Â·  3Comments