TypeScript: Unions without discriminant properties do not perform excess property checking

TypeScript Version: 2.7.0-dev.201xxxxx

Code

// An object to hold all the possible options
type AllOptions = {
    startDate: Date
    endDate: Date
    author: number
}

// Any combination of startDate, endDate can be used
type DateOptions =
    | Pick<AllOptions, 'startDate'>
    | Pick<AllOptions, 'endDate'>
    | Pick<AllOptions, 'startDate' | 'endDate'>

type AuthorOptions = Pick<AllOptions, 'author'>

type AllowedOptions = DateOptions | AuthorOptions

const options: AllowedOptions = {
    startDate: new Date(),
    author: 1
}

Expected behavior:

An error that options cannot be coerced into type AllowedOptions because startDate and author cannot appear in the same object.

Actual behavior:

It compiles fine.

About this issue

  • Original URL
  • State: open
  • Created 7 years ago
  • Reactions: 45
  • Comments: 33 (17 by maintainers)

Most upvoted comments

@svatal I have a workaround for this in the form of StrictUnion. I also have a decent writeup of it here:

type UnionKeys<T> = T extends unknown ? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends unknown ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>

type IItem = StrictUnion<{a: string} & ({b?: false} | { b: true, requiredWhenB: string })>

function x(i: IItem) { }

x({ a: 'a' })   // ok
x({ a: 'a', b: false }) // ok
x({ a: 'a', unknownProp: 1 })   // ok, failed because of unknown property
x({ a: 'a', b: false, requiredWhenB: "x"})  // ok, failed because of unknown property
x({ a: 'a', requiredWhenB: "x"})    // fails now
x({ a: 'a', requiredWhenB: true})    // fails now
x({ a: 'a', b: undefined, requiredWhenB: "x"})    // this one is still ok if strictNullChecks are off.
x({ a: 'a', b: true })  // ok, failed because of missing required property
x({ a: 'a', b: true, requiredWhenB: "x" })  // ok

Just thought I’d bring up another approach to “human-readability”,

type UnionKeys<T> = 
  T extends unknown ?
  keyof T :
  never;
  
type InvalidKeys<K extends string|number|symbol> = { [P in K]? : never };
type StrictUnionHelper<T, TAll> =
  T extends unknown ?
  (
    & T
    & InvalidKeys<Exclude<UnionKeys<TAll>, keyof T>>
  ) :
  never;

type StrictUnion<T> = StrictUnionHelper<T, T>

/*
type t = (
    {a: string;} & InvalidKeys<"b" | "c">) |
    ({b: number;} & InvalidKeys<"a" | "c">) |
    ({c: any;} & InvalidKeys<"a" | "b">)
*/
type t = StrictUnion<{a: string} | {b: number} | {c: any}>

/*
type noisyUnion = ({
    a: string;
    b: string;
    c: number;
} & InvalidKeys<"x" | "y" | "z" | "i" | "j" | "k" | "l" | "m" | "n">) | ({
    x: string;
    y: number;
    z: boolean;
} & InvalidKeys<"a" | "b" | "c" | "i" | "j" | "k" | "l" | "m" | "n">) | ({
    ...;
} & InvalidKeys<...>) | ({
    ...;
} & InvalidKeys<...>)
*/
type noisyUnion = StrictUnion<
    | {a:string,b:string,c:number}
    | {x:string,y:number,z:boolean}
    | {i:Date,j:any,k:unknown}
    | {l:1,m:"hello",n:1337}
>;

Playground

If InvalidKeys is too verbose, you can make the name as short as an underscore, Playground


With Compute,

export type Compute<A extends any> =
    {[K in keyof A]: A[K]} extends infer X
    ? X
    : never

type UnionKeys<T> = 
  T extends unknown
  ? keyof T
  : never;

type StrictUnionHelper<T, TAll> =
  T extends unknown
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>>
  : never;

type StrictUnion<T> = Compute<StrictUnionHelper<T, T>>

/*
type t = {
    a: string;
    b?: undefined;
    c?: undefined;
} | {
    b: number;
    a?: undefined;
    c?: undefined;
} | {
    c: any;
    a?: undefined;
    b?: undefined;
}
*/
type t = StrictUnion<{a: string} | {b: number} | {c: any}>

/*
type noisyUnion = {
    a: string;
    b: string;
    c: number;
    x?: undefined;
    y?: undefined;
    z?: undefined;
    i?: undefined;
    j?: undefined;
    k?: undefined;
    l?: undefined;
    m?: undefined;
    n?: undefined;
} | {
    x: string;
    y: number;
    z: boolean;
    a?: undefined;
    ... 7 more ...;
    n?: undefined;
} | {
    ...;
} | {
    ...;
}
*/
type noisyUnion = StrictUnion<
    | {a:string,b:string,c:number}
    | {x:string,y:number,z:boolean}
    | {i:Date,j:any,k:unknown}
    | {l:1,m:"hello",n:1337}
>;

Playground


I like the Compute approach when the union is not “noisy” (the ratio of invalid to valid keys is closer to 0 or 0.5).

I like the InvalidKeys approach when the union is “noisy” (the ratio of invalid to valid keys is closer to one or higher).

On review of this, it seems like the scenario in the OP isn’t something we could feasibly touch at this point - it’s been the behavior of EPC for unions since either of those features existed, and people write quasioverlapping unions fairly often.

The solutions described above are all pretty good and honestly much more expressive than just the originally-proposed behavior change, so I’d recommend choosing one of those based on what you want to happen.

Overall Exact/Closed types seem like the real underlying request for most of these scenarios, so follow #12936 for next steps there.

Contextual typing is not relevant here. Neither is Pick. Here’s a smaller repro that avoids contextual typing:

type A = { d: Date, e: Date } | { n: number }
const o = { d: new Date(), n: 1 }
const a: A = o

Excess property checking of unions does not work because all it does is try to find a single member of the union to check for excess properties. It does this based on discriminant properties, but A doesn’t have any overlapping properties, so it can’t possibly have any discriminant properties. (The same is true of the original AllowedOptions.) So excess property checking is just skipped. I chose this design because it was conservative enough to avoid bogus errors, if I remember correctly.

To get excess property checks for unions without discriminant properties, we could just choose the first union member that has at least one property shared with the source, but that would behave badly with the original example:

const options: AllowedOptions = {
  startDate: new Date(),
  endDate: new Date()
}

Here, we’d choose Pick<AllOptions, 'startDate'> as the first matching union member and then mark endDate as an excess property.

@sylvanaar @DanielRosenwasser Let me know if you have ideas for some other way to get excess property checks for unions without discriminant properties.

I think this might be another example:

type A = {
  onlyA: boolean;
  optionalA?: boolean;
};

type B = {
  onlyB: boolean;
  optionalB?: boolean;
};

type C = {
  onlyC: boolean;
  optionalC?: boolean;
};

type Choices = ["A", A] | ["B", B] | ["C", C];

function fn<T extends Choices>(choice: T) {
  return choice;
}

fn(["A", { onlyA: true, onlyB: true }]); // Should error but doesn't
fn(["B", { onlyB: true, optionalC: true }]); // Should error but doesn't

const a: A = {
  onlyA: true,
  onlyB: true, // Errors as expected
};

const b: B = {
  onlyB: true,
  optionalC: true, // Errors as expected
};

You can also see in this screencapture that TypeScript suggests properties and combinations that shouldn’t be allowed:

https://github.com/microsoft/TypeScript/assets/7614039/1285dfdb-eb4a-4879-ba9b-5e6eaa7c0aaf

@SlurpTheo

import {U} from 'ts-toolbelt'

type SU = U.Strict<{d: Date} | {(): boolean}>

const aG1: SU = () => true; 
const aG2: SU = { d: new Date() };

This happens because we should never compute a function type by intersecting it with an object, Compute from the ts-toolbelt handles this scenario.

I think I just ran into this issue with this snippet,

type Foo = {
    a : number,
    b : number,
};
type Bar = {
    a : number,
    c : number,
};

const foo : Foo = {
    a : 0,
    b : 0,
    //OK!
    //Object literal may only specify known properties
    c : 0,
};

const bar : Bar = {
    a : 0,
    //OK!
    //Object literal may only specify known properties
    b : 0,
    c : 0,
};

/**
 * Expected: Object literal may only specify known properties
 * Actual  : No errors
 */
const fooOrBar : Foo|Bar = {
    a : 0,
    b : 0,
    c : 0,
};

Playground

I know TS only performs excess property checks on object literals. But I also need to perform excess property checks when the destination type is a union.

Seems like it doesn’t do that at the moment.

@dragomirtitian 's workaround works but it does make the resulting type much uglier.

Another example:

declare function f(options: number | { a: { b: number } }): void;
f({ a: { b: 0, c: 1 } });

Built-in type guards pretend that TS has excess property checking on unions, when really it doesn’t.

type Step =
  | { name: string }
  | {
      name: string;
      start: Date;
      data: Record<string, unknown>;
    };

const step: Step = {
  name: 'initializing',
  start: new Date(),
};

if ('start' in step) {
  console.log(step.data.thing);
}

EDIT: I was fooled by comment-compression into thinking the type-guard case was not mentioned. Apologies for the repetition.

Overall Exact/Closed types seem like the real underlying request for most of these scenarios, so follow #12936 for next steps there.

Wowsa, #12936 is quite the chain there 😺 After skimming I don’t immediately follow how it will help with providing something like a Strict Union (i.e., really don’t want to allow excess properties), but I have subscribed.

@AnyhowStep @pirix-gh I always use this type to expand out other types, not necessarily better just another option:

export type Compute<A extends any> = {} & { // I usually call it Id
  [P in keyof A]: A[P]
}

Also I tend to use it sparingly, if the union constituents are named, the expanded version is arguably worse IMO.

In addition to @dragomirtitian’s StrictUnion, I would just add Compute to make it human-readable:

import {A} from 'ts-toolbelt'

type UnionKeys<T> = 
  T extends unknown
  ? keyof T
  : never;

type StrictUnionHelper<T, TAll> =
  T extends unknown
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>>
  : never;

type StrictUnion<T> = A.Compute<StrictUnionHelper<T, T>>

type t = StrictUnion<{a: string} | {b: number} | {c: any}>

Someone can confirm for me that this is a bug, it seems like a excess property checking bug, making it validate on the first property to match one seems like the logical thing to happen? If it is I can make a PR @sandersn @DanielRosenwasser ?