Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ccb31dd
add: `LastOfUnion`, return a type of an union-type (order is not guar…
taiyakihitotsu Feb 5, 2026
397ebf4
refactor: `UnionToTuple`, import `LastOfUnion` instead of define
taiyakihitotsu Feb 5, 2026
167f3eb
Add `ExcludeExactly`, distinguish between different modifiers.
taiyakihitotsu Feb 5, 2026
08bc31c
fix: `UnionToTuple`, use `ExcludeExactly`, improve performance.
taiyakihitotsu Feb 5, 2026
b24f977
chore: fix doc
taiyakihitotsu Feb 5, 2026
0dbb9c0
doc: `ExcludeExactly`, fix arrow test cases
taiyakihitotsu Feb 5, 2026
7ef927a
Update last-of-union.ts
sindresorhus Feb 6, 2026
e95c1ee
doc: address review
taiyakihitotsu Feb 6, 2026
3b54932
test: `UnionToTuple`, super type test cases
taiyakihitotsu Feb 6, 2026
8b984c9
test: add test cases
taiyakihitotsu Feb 6, 2026
4baac7e
update: refine `ExcludeExactly`, update test-cases.
taiyakihitotsu Feb 7, 2026
0699dc0
update: move `SimpleIsEqual` to `internal`
taiyakihitotsu Feb 7, 2026
01cbb49
test: `LastOfUnion` ensures all union members would be picked.
taiyakihitotsu Feb 7, 2026
408b2de
test: `test-d/last-of-union.ts`, remove `any` and `unknown` checker t…
taiyakihitotsu Feb 7, 2026
614ad2a
test: add edge cases.
taiyakihitotsu Feb 10, 2026
e4bd1e8
update: remove `SimpleIsEqual`, move `LastOfUnion` to `internal`, del…
taiyakihitotsu Feb 11, 2026
65cc0bd
refactor: remove unused imports and tests
taiyakihitotsu Feb 12, 2026
68c4798
doc: cleanup JSDoc
som-sm Feb 13, 2026
78fcde9
fix: README description
som-sm Feb 13, 2026
6822834
test: improve `ExcludeExactly` tests
som-sm Feb 13, 2026
5dce64e
test: add note regarding `DifferentModifierUnion`
som-sm Feb 13, 2026
9579825
refactor: revert `LastOfUnion` and `IsEqual` comment, delete unused t…
taiyakihitotsu Feb 13, 2026
2d728ee
chore: remove `LastOfUnion` changes
som-sm Feb 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export type {IsNullable} from './source/is-nullable.d.ts';
export type {TupleOf} from './source/tuple-of.d.ts';
export type {ExclusifyUnion} from './source/exclusify-union.d.ts';
export type {ArrayReverse} from './source/array-reverse.d.ts';
export type {LastOfUnion} from './source/last-of-union.d.ts';

// Template literal types
export type {CamelCase, CamelCaseOptions} from './source/camel-case.d.ts';
Expand Down Expand Up @@ -207,5 +208,6 @@ export type {TsConfigJson} from './source/tsconfig-json.d.ts';
export type {ExtendsStrict} from './source/extends-strict.d.ts';
export type {ExtractStrict} from './source/extract-strict.d.ts';
export type {ExcludeStrict} from './source/exclude-strict.d.ts';
export type {ExcludeExactly} from './source/exclude-exactly.d.ts';

export {};
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Click the type names for complete docs.
- [`ConditionalSimplify`](source/conditional-simplify.d.ts) - Simplifies a type while including and/or excluding certain types from being simplified.
- [`ConditionalSimplifyDeep`](source/conditional-simplify-deep.d.ts) - Recursively simplifies a type while including and/or excluding certain types from being simplified.
- [`ExclusifyUnion`](source/exclusify-union.d.ts) - Ensure mutual exclusivity in object unions by adding other members’ keys as `?: never`.
- [`LastOfUnion`](source/last-of-union.d.ts) - Return a member of a union type. Order is not guaranteed.

### Type Guard

Expand Down Expand Up @@ -319,6 +320,7 @@ Click the type names for complete docs.
- [`ExtendsStrict`](source/extends-strict.d.ts) - A stricter, non-distributive version of `extends` for checking whether one type is assignable to another.
- [`ExtractStrict`](source/extract-strict.d.ts) - A stricter version of `Extract<T, U>` that ensures every member of `U` can successfully extract something from `T`.
- [`ExcludeStrict`](source/exclude-strict.d.ts) - A stricter version of `Exclude<T, U>` that ensures every member of `U` can successfully exclude something from `T`.
- [`ExcludeExactly`](source/exclude-exactly.d.ts) - A stricter version of `Exclude<T, U>` that ensures objects with different key modifiers are not considered identical.

## Declined types

Expand Down
124 changes: 124 additions & 0 deletions source/exclude-exactly.d.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this implementation simpler/better? It passes all the existing tests and also works as suggested with any and unknown.

export type ExcludeExactly<Union, Delete> =
	IfNotAnyOrNever<
		Union,
		_ExcludeExactly<Union, Delete>,
		// If `Union` is `any`, then if `Delete` is `any`, return `never`, else return `Union`.
		If<IsAny<Delete>, never, Union>,
		// If `Union` is `never`, then if `Delete` is `never`, return `never`, else return `Union`.
		If<IsNever<Delete>, never, Union>
	>;

type _ExcludeExactly<Union, Delete> =
	IfNotAnyOrNever<Delete,
		Union extends unknown // For distributing `Union`
			? [Delete extends unknown // For distributing `Delete`
				? If<SimpleIsEqual<Union, Delete>, true, never>
				: never] extends [never] ? Union : never
			: never,
		// If `Delete` is `any` or `never`, then return `Union`,
		// because `Union` cannot be `any` or `never` here.
		Union, Union
	>;

type SimpleIsEqual<A, B> =
	(<G>() => G extends A & G | G ? 1 : 2) extends
	(<G>() => G extends B & G | G ? 1 : 2)
		? true
		: false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your definition doesn't use LastOfUnion and is clearer about the comparison part, since the SimpleIsEqual pattern is well-known, and all tests pass.
But SimpleIsEqual is already defined in is-equal.d.ts, so should we move it to internal, or even export it? Is that ok?
(I vaguely think it is acceptable because this comparison construct is useful to define some type functions.)

And in this situation, LastOfUnion is still worth exporting as a standalone utility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type {IsUnknown} from './is-unknown.d.ts';
import type {IsNever} from './is-never.d.ts';
import type {IsAny} from './is-any.d.ts';
import type {LastOfUnion} from './last-of-union.d.ts';

/**
Return `never` if the first and second arguments are identical.
Return the first argument if not.
(But there's a limitation about union/intersection type. See `IsEqual` in `source/is-equal.d.ts`.)

@example
```
type A = MatchOrNever<string | number, string>;
//=> string | number
type B = MatchOrNever<string | number, string | number>;
//=> never
type C = MatchOrNever<string | number, unknown>;
//=> string | number
type D = MatchOrNever<string, string | number>;
//=> string
```

This does NOT depend on assignability.

@example
```
type RO_0 = MatchOrNever<{readonly a: 0}, {a: 0}>;
//=> {readonly a: 0}
type RO_1 = MatchOrNever<{a: 0}, {readonly a: 0}>;
//=> {a: 0}
```

`unknown` and `never` cases, which easily break equality in type-level codebase.

@example
```
type E = MatchOrNever<unknown, never>;
//=> unknown
type F = MatchOrNever<unknown, unknown>;
//=> never
type G = MatchOrNever<never, never>;
//=> never
type H = MatchOrNever<never, unknown>;
//=> never
```

Note that this doesn't regard the identical union/intersection type `T | T` and/or `T & T` as `T` recursively.
e.g., `{a: 0} | {a: 0}` and/or `{a: 0} & {a: 0}` as `{a: 0}`.

@example
```
type IDUnion = MatchOrNever<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>;
//=> never
type A = {a: {b: 0} | {b: 0}};
type RecurivelyIDUnion = MatchOrNever<A, {a: {b: 0}}>;
//=> A
```
*/
type MatchOrNever<A, B> =
[unknown, B] extends [A, never]
? A
// This equality code base below doesn't work if `A` is `unknown` and `B` is `never` case.
// So this branch should be wrapped to take care of this.
: (<G>() => G extends A & G | G ? 1 : 2) extends (<G>() => G extends B & G | G ? 1 : 2)
? never
: A;

/**
A stricter version of `Exclude<T, U>` that ensures objects with different key modifiers are not considered identical.

TypeScript's built-in `Exclude` and `ExcludeStrict` in `type-fest` don't distinguish key modifiers of objects.

@example
```
import type {ExcludeStrict} from 'type-fest';

type NeverReturned_0 = Exclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>;
//=> never
type NeverReturned_1 = ExcludeStrict<{a: 0} | {readonly a: 0}, {readonly a: 0}>;
//=> never
```

`ExcludeExactly` keeps the union members element if the members are not identical.

@example
```
import type {ExcludeExactly} from 'type-fest';

type ExcludeNever = ExcludeExactly<{a: 0} | {a: 0} | {readonly a: 0}, never>;
//=> {a: 0} | {a: 0} | {readonly a: 0}
type ExcludeReadonlyKey = ExcludeExactly<{a: 0} | {readonly a: 0}, {readonly a: 0}>;
//=> {a: 0}
type ExcludeKey = ExcludeExactly<{readonly a: 0}, {a: 0}>;
//=> {readonly a: 0}
type ExcludeReadonly = ExcludeExactly<{readonly a: 0}, {readonly a: 0}>;
//=> never
type ExcludeSubType = ExcludeExactly<0 | 1 | number, 1>;
//=> number
type ExcludeAllSet = ExcludeExactly<0 | 1 | number, number>;
//=> never
type ExcludeFromUnknown = ExcludeExactly<unknown, string>;
//=> unknown
type ExcludeFromUnknownArray = ExcludeExactly<number[] | unknown[], number[]>;
//=> unknown[]
```

@category Improved Built-in
*/
export type ExcludeExactly<UnionU, DeleteT> =
LastOfUnion<DeleteT> extends infer D
? true extends IsNever<D>
? UnionU
: ExcludeExactly<_ExcludeExactly<UnionU, D>, _ExcludeExactly<DeleteT, D>>
: never;

type _ExcludeExactly<UnionU, DeleteT> =
true extends IsAny<DeleteT>
? never
: true extends IsUnknown<DeleteT>
? never
: UnionU extends unknown // Only for union distribution.
? MatchOrNever<UnionU, DeleteT>
: never;
export {};
46 changes: 46 additions & 0 deletions source/last-of-union.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type {IsNever} from './is-never.d.ts';
import type {UnionToIntersection} from './union-to-intersection.d.ts';

/**
Return a member of a union type. Order is not guaranteed.
Returns `never` when the input is `never`.

@see https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468375328

Use-cases:
- Implementing recursive type functions that accept a union type.
- Reducing a union one member at a time, for example when building tuples.

It can detect a termination case using {@link IsNever `IsNever`}.

@example
```
import type {LastOfUnion, ExcludeExactly, IsNever} from 'type-fest';

export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<ExcludeExactly<T, L>>, L]
: [];
```

@example
```
import type {LastOfUnion} from 'type-fest';

type Last = LastOfUnion<1 | 2 | 3>;
//=> 3

type LastNever = LastOfUnion<never>;
//=> never
```

@category Type
*/
export type LastOfUnion<T> =
true extends IsNever<T>
? never
: UnionToIntersection<T extends any ? () => T : never> extends () => (infer R)
? R
: never;

export {};
21 changes: 5 additions & 16 deletions source/union-to-tuple.d.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create another PR for the purpose of exposing LastOfUnion and move all these changes there.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this PR only for fixing UnionToTuple and adding new ExcludeExactly type.

Copy link
Copy Markdown
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@som-sm

Yes.
I've opened LastOfUnion PR, and updated this PR's title.

Note:
test-d/last-of-union.ts in #1368 uses the old (my defined) ExcludeExactly which is written with LastOfUnion, because LastOfUnion should also be guaranteed to keep its union members.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the expected workflow:

How does this look?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR uses the old (my defined) ExcludeExactly which is written with LastOfUnion, because LastOfUnion should also be guaranteed to keep its union members.

Didn't get this. Can't see ExcludeExactly using LastOfUnion.

Copy link
Copy Markdown
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import type {IsNever} from './is-never.d.ts';
import type {UnionToIntersection} from './union-to-intersection.d.ts';

/**
Returns the last element of a union type.

@example
```
type Last = LastOfUnion<1 | 2 | 3>;
//=> 3
```
*/
type LastOfUnion<T> =
UnionToIntersection<T extends any ? () => T : never> extends () => (infer R)
? R
: never;
import type {ExcludeExactly} from './exclude-exactly.d.ts';
import type {LastOfUnion} from './last-of-union.d.ts';

/**
Convert a union type into an unordered tuple type of its elements.
Expand Down Expand Up @@ -52,7 +39,9 @@ const petList = Object.keys(pets) as UnionToTuple<Pet>;
*/
export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<Exclude<T, L>>, L]
? ExcludeExactly<T, L> extends infer E // Improve performance.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a test that validates this perf improvement?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can there be a test that validates this perf improvement?

Currently, no.
I haven’t started working on this yet...


(This is the comment about this gc issue I posted firstly.)

I made a branch to reproduce this.

This commit, taiyakihitotsu@e8f0967, fixes the issue.
And npm test will crash if this is reverted.


The error logs don't explicitly point to UnionToTuple as the cause, and it’s not a standard TS2589 error.

We might need to implement granular tests for individual type definitions within the package, rather than just running a full type-check on test files.
Without these, it’s difficult to pinpoint exactly where the performance bottleneck or crash is originating.

Also, if we do implement such tests, we’ll need to separately discuss where to set the thresholds (e.g., memory limits or complexity scores).

? [...UnionToTuple<E>, L]
: never // Unreachable.
: [];

export {};
39 changes: 39 additions & 0 deletions test-d/exclude-exactly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {expectType} from 'tsd';
import type {ExcludeExactly} from '../index.d.ts';

expectType<number>({} as ExcludeExactly<0 | 1 | number, '1'>);
expectType<never>({} as ExcludeExactly<0 | 1 | number, number>);
expectType<string>({} as ExcludeExactly<'0' | '1' | string, '1'>);
expectType<never>({} as ExcludeExactly<'0' | '1' | string, string>);

// `{readonly a: t}` should not be equal to `{a: t}` because of assignability.
expectType<{a: 0}>({} as ExcludeExactly<{a: 0} | {readonly a: 0}, {readonly a: 0}>);
expectType<{readonly a: 0}>({} as ExcludeExactly<{readonly a: 0}, {a: 0}>);
expectType<never>({} as ExcludeExactly<{readonly a: 0}, {readonly a: 0}>);

// `never` does nothing.
expectType<0 | 1 | 2>({} as ExcludeExactly<0 | 1 | 2, never>);
expectType<never>({} as ExcludeExactly<never, never>);

// `unknown` cannot be excluded like `unknown\T` in any cases.
expectType<unknown>({} as ExcludeExactly<unknown, string>);
expectType<[unknown]>({} as ExcludeExactly<[unknown], [number]>);
expectType<unknown[]>({} as ExcludeExactly<unknown[], number[]>);
expectType<{a: unknown}>({} as ExcludeExactly<{a: unknown}, {a: number}>);
expectType<unknown[]>({} as ExcludeExactly<number[] | unknown[], number[]>);

// `unknown` and `any` exclude themselves.
expectType<never>({} as ExcludeExactly<unknown, unknown>);
expectType<never>({} as ExcludeExactly<unknown, any>);
expectType<never>({} as ExcludeExactly<any, any>);
expectType<never>({} as ExcludeExactly<any, unknown>);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// unknown and any exclude themselves.

Why? Is this a requirement or a limitation of the existing implementation?

If this type does an equality check (instead of an extends check), then IMO the ideal behaviour should be:

expectType<unknown>({} as ExcludeExactly<unknown, any>);
expectType<any>({} as ExcludeExactly<any, unknown>);

Copy link
Copy Markdown
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// unknown and any exclude themselves.

Why? Is this a requirement or a limitation of the existing implementation?

ExcludeExactly<A, B> would be expected to return $(A \cup B) \setminus B$.
If $B$ is unknown or any, $A \subseteq B$ must be true, so it's expected to return $\emptyset$ (never).

#1349 (comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expectType<any>({} as ExcludeExactly<any, unknown>);

In this case, returning any might actually be fine, as you suggested.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExcludeExactly<A, B> would be expected to return $(A \cup B) \setminus B$.

Isn't $(A \cup B) \setminus B$ same as $A \setminus B$?

If ExcludeExactly<A, B> is doing just $A \setminus B$, what’s the need for it? The built-in Exclude type already provides that behavior.

Also, if ExcludeExactly<A, B> is $A \setminus B$, then shouldn't ExcludeExactly<1, number> return never, instead of 1?


Wasn't the whole point of building ExcludeExactly that it shouldn't behave like $A \setminus B$?


Currently, this type is not consistent in its behavior. Sometimes it behaves like $A \setminus B$ (like ExcludeExactly<1, any>), but sometimes it does not (like ExcludeExactly<1, number>).

Copy link
Copy Markdown
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, completely my bad...

Firstly, not $(A \cup B) \setminus B$, $A \setminus B$ is right (as you pointed it out).

Secondly, I confused some cases: if A is 1, number, 1 | number, and if B is number or unknown.
1 | number means number, so ExcludeExactly properly removes it with ExcludeExactly<1 | number, number>.
This shouldn't mean ExcludeExactly<1, number> returns never.
For the same reason, ExcludeExactly<A, unknown> or , any> shouldn't return never.

You're right, and I think it should use your definition of ExcludeExactly.
I've addressed it, moving SimpleIsEqual to internal and adding test cases for it (4baac7e and 0699dc0).


// `unknown` and `any` exclude other types.
expectType<never>({} as ExcludeExactly<string | number, unknown>);
expectType<never>({} as ExcludeExactly<string | number, any>);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question here.

Should be this IMO:

expectType<string | number>({} as ExcludeExactly<string | number, unknown>);
expectType<string | number>({} as ExcludeExactly<string | number, any>);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Union
expectType<2>({} as ExcludeExactly<0 | 1 | 2, 0 | 1>);
expectType<never>({} as ExcludeExactly<0 | 1 | 2, 0 | 1 | 2>);
expectType<{readonly a?: 0}>({} as ExcludeExactly<{a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}, {a: 0} | {readonly a: 0} | {a?: 0}>);
expectType<never>({} as ExcludeExactly<{a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}, {a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}>);
13 changes: 13 additions & 0 deletions test-d/last-of-union.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {expectType} from 'tsd';
import type {LastOfUnion, IsAny, IsUnknown} from '../index.d.ts';

// `LastOfUnion` distinguishes between different modifiers.
type UnionType = {a: 0} | {b: 0} | {a?: 0} | {readonly a?: 0} | {readonly a: 0};
expectType<true>({} as LastOfUnion<UnionType> extends UnionType ? true : false);
expectType<false>({} as UnionType extends LastOfUnion<UnionType> ? true : false);

// `never` acts as a termination condition with `IsNever`.
expectType<never>({} as LastOfUnion<never>);

expectType<true>({} as IsUnknown<LastOfUnion<unknown>>);
expectType<true>({} as IsAny<LastOfUnion<any>>);
11 changes: 11 additions & 0 deletions test-d/union-to-tuple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ expectType<Options1[number]>({} as (1 | 2 | 3));

type Options2 = UnionToTuple<boolean | 1>;
expectType<Options2[number]>({} as (1 | false | true));

// Different modifiers cases.
type DifferentModifiers = {a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0};
expectType<UnionToTuple<DifferentModifiers>[number]>({} as DifferentModifiers);

// Super type cases.
type UnionSuperType0 = {a: string; b: string} | {a: string};
expectType<UnionSuperType0>({} as UnionToTuple<UnionSuperType0>[number]);

type UnionSuperType1 = {a: 1} | {b: 1} | {[x: string]: number};
expectType<UnionSuperType1>({} as UnionToTuple<UnionSuperType1>[number]);