Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,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 {};
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,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 excludes types only when they are exactly identical.

## Declined types

Expand Down
57 changes: 57 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,57 @@
import type {IsNever} from './is-never.d.ts';
import type {IsAny} from './is-any.d.ts';
import type {If} from './if.d.ts';
import type {IsEqual} from './is-equal.d.ts';
import type {IfNotAnyOrNever} from './internal/type.d.ts';

/**
A stricter version of `Exclude<T, U>` that excludes types only when they are exactly identical.
@example
```
import type {ExcludeExactly} from 'type-fest';
type TestExclude1 = Exclude<'a' | 'b' | 'c' | 1 | 2 | 3, string>;
//=> 1 | 2 | 3
type TestExcludeExactly1 = ExcludeExactly<'a' | 'b' | 'c' | 1 | 2 | 3, string>;
//=> 'a' | 'b' | 'c' | 1 | 2 | 3
type TestExclude2 = Exclude<'a' | 'b' | 'c' | 1 | 2 | 3, any>;
//=> never
type TestExcludeExactly2 = ExcludeExactly<'a' | 'b' | 'c' | 1 | 2 | 3, any>;
//=> 'a' | 'b' | 'c' | 1 | 2 | 3
type TestExclude3 = Exclude<{a: string} | {a: string; b: string}, {a: string}>;
//=> never
type TestExcludeExactly3 = ExcludeExactly<{a: string} | {a: string; b: string}, {a: string}>;
//=> {a: string; b: string}
```
@category Improved Built-in
*/
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<IsEqual<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
>;

export {};
1 change: 1 addition & 0 deletions source/internal/type.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {IsAny} from '../is-any.d.ts';
import type {IsNever} from '../is-never.d.ts';
import type {Primitive} from '../primitive.d.ts';
import type {UnknownArray} from '../unknown-array.d.ts';
import type {UnionToIntersection} from '../union-to-intersection.d.ts';

/**
Matches any primitive, `void`, `Date`, or `RegExp` value.
Expand Down
1 change: 0 additions & 1 deletion source/is-equal.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type {IsNever} from './is-never.d.ts';
/**
Returns a boolean for whether the two given types are equal.
Expand Down
3 changes: 2 additions & 1 deletion 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,3 +1,4 @@
import type {ExcludeExactly} from './exclude-exactly.d.ts';
import type {IsNever} from './is-never.d.ts';
import type {UnionToIntersection} from './union-to-intersection.d.ts';

Expand Down Expand Up @@ -52,7 +53,7 @@ const petList = Object.keys(pets) as UnionToTuple<Pet>;
*/
export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<Exclude<T, L>>, L]
? [...UnionToTuple<ExcludeExactly<T, L>>, L]
: [];

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

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

expectType<{a: 0}>({} as ExcludeExactly<{a: 0} | {readonly a: 0}, {readonly a: 0}>);
expectType<{readonly a: 0}>({} as ExcludeExactly<{a: 0} | {readonly a: 0}, {a: 0}>);
expectType<never>({} as ExcludeExactly<{readonly a: 0}, {readonly a: 0}>);

// `never` excludes nothing
expectType<0 | 1 | 2>({} as ExcludeExactly<0 | 1 | 2, never>);
expectType<never>({} as ExcludeExactly<never, never>);
expectType<any>({} as ExcludeExactly<any, never>);
expectType<unknown>({} as ExcludeExactly<unknown, never>);

// Excluding from `unknown`/`any`
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[]>);
expectType<any>({} as ExcludeExactly<any, string>);
expectType<[any]>({} as ExcludeExactly<[any], [number]>);
expectType<any[]>({} as ExcludeExactly<any[], number[]>);
expectType<{a: any}>({} as ExcludeExactly<{a: any}, {a: number}>);
expectType<any[]>({} as ExcludeExactly<number[] | any[], number[]>);

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

// Unions
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}
>);
12 changes: 12 additions & 0 deletions test-d/union-to-tuple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,15 @@ expectType<Options1[number]>({} as (1 | 2 | 3));

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

// Test for https://github.com/sindresorhus/type-fest/issues/1352
// This union is special because `{readonly a: 0}` extends `{a: 0}`, and `{a: 0}` also extends `{readonly a: 0}`,
// meaning both types are assignable to each other.
// See [this comment](https://github.com/sindresorhus/type-fest/pull/1349#issuecomment-3858719735) for more details.
type DifferentModifierUnion = {readonly a: 0} | {a: 0};
expectType<DifferentModifierUnion>({} as UnionToTuple<DifferentModifierUnion>[number]);

// Edge cases.
expectType<[]>({} as UnionToTuple<never>);
expectType<[any]>({} as UnionToTuple<any>);
expectType<[unknown]>({} as UnionToTuple<unknown>);