Max Heiber
Max Heiber

Reputation: 15562

Why doesn't TypeScript `Exclude<T, U>` exclude? Is this a bug?

This is hard to explain, but I'm trying to filter out a certain type in a subfield of an object type.

Exclude<T, U> doesn't seem to work with this kind of nesting:

type PrivateName = { kind: "PrivateName" };
type Identifier = { kind: "Identifier" };

type NamedNode = {
  name: PrivateName | Identifier;
};

type PrivateNamedNode = {
  name: PrivateName;
};

declare const privateName: PrivateName;

type NamedNodeNonPrivate = Exclude<NamedNode, PrivateNamedNode>;

// I expected this next line to error, but it doesn't!
const x: NamedNodeNonPrivate = { name: privateName };

Why didn't the last line error? Should I open a bug report?

Tested on TS versions: 3.5.1, 3.3.3, 3.1.6, and 3.0.1. playground link.

Potential Root Cause

The definition of Exclude<T, U> is

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

It seems to be inert in my example because NamedNode doesn't extend { name: PrivateName }.

Changing the definition as follows produces the desired error message. Unfortunately, this isn't a refactor I can do manually to the whole codebase:

type NamedNode = {
  name: PrivateName
} | {
  name: Identifier
}

The Curry-Howard Isomorphism tells us that product types are like conjunction and sum types are like disjunction.

The following holds in Boolean logic:

A & (B or C) implies (A & B) or (A & C)

So it seems reasonable that the two definitions of NamedNode would be equivalent. Is this lack of distributivity:

Upvotes: 0

Views: 393

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250136

{ name: PrivateName | Identifier; }; and { name: PrivateName } | { name: Identifier } are equivalent only in the trivial case when there object type contains only this property. The common use case is that the different branches of the union will hold different type.

Regardless of this, conditional types do distribute but only over naked type parameters. No magic distribution occurs over properties of every type. And since the relation NamedNode extends PrivateNamedNode is not true no types will be excluded (no type can be excluded since NamedNode is not a union, even if the relation were true, you would get never since then the whole type would be excluded and you would be left with nothing).

We can build a type that excludes types from properties of an object:

type PrivateName = { kind: "PrivateName" };
type Identifier = { kind: "Identifier" };

type NamedNode = {
    name: PrivateName | Identifier;
};

type PrivateNamedNode = {
    name: PrivateName;
};

declare const privateName: PrivateName;

type ExcludePropertyTypes<T, TExclude extends Partial<T>> {
    [P in keyof T]: Exclude<T[P], TExclude[P]>
}

type NamedNodeNonPrivate = ExcludePropertyTypes<NamedNode, PrivateNamedNode>;

// Errors now (as intended)
const x: NamedNodeNonPrivate = { name: privateName };

Playground link

Upvotes: 1

Related Questions