From dabe872cccfa18527525409fdbab5447a070c2ee Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Tue, 27 Jan 2026 21:45:45 +0900 Subject: [PATCH 01/31] fix: `IsEqual`, `{a: t, b: s}` and `{a: t} & {b: s}` are equal --- source/is-equal.d.ts | 11 ++++++----- test-d/is-equal.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index b0183048d..8c6d919f1 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,4 +1,5 @@ -import type {IsNever} from './is-never.d.ts'; +import type {Simplify} from './simplify.d.ts'; + /** Returns a boolean for whether the two given types are equal. @@ -26,10 +27,10 @@ type Includes = @category Utilities */ export type IsEqual = - [A] extends [B] - ? [B] extends [A] - ? _IsEqual - : false + [A, B] extends [B, A] + ? [A, B] extends [object, object] + ? _IsEqual, Simplify> + : _IsEqual : false; // This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index c7856c540..1d31af85c 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {IsEqual, TupleOf} from '../index.d.ts'; +import type {IsEqual, TupleOf, Merge, Simplify, Except} from '../index.d.ts'; expectType({} as IsEqual); expectType({} as IsEqual<1, 1>); @@ -86,6 +86,9 @@ expectType(equalTupleIntersectionToBeNeverAndNeverExpanded); declare const equalTupleIntersectionAndTuple: IsEqual<[{a: 1}] & [{a: 1}], [{a: 1}]>; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType(equalTupleIntersectionAndTuple); -// Test for Issue https://github.com/sindresorhus/type-fest/issues/1305 -type Assignability> = any; -type TestAssignability = Assignability; +// Distinct whether an object is merged by `&` or via `Simplify`, to ensure Branded Types are handled strictly. +export type IntersectionMerge = Except & {__brand: 'tag'}; +type SampleTuple = [0, 1, 2]; + +expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual>, Merge, IntersectionMerge>>); From 74b0e71d09688829cfa2852cb3f5b0c0807250df Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 03:03:39 +0900 Subject: [PATCH 02/31] Fix: `IsEqual`, returns `true` for nested intersection of objects --- source/is-equal.d.ts | 3 ++- test-d/is-equal.ts | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 8c6d919f1..b752d0a5c 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,4 +1,5 @@ import type {Simplify} from './simplify.d.ts'; +import type {SimplifyDeep} from './simplify-deep.d.ts'; /** Returns a boolean for whether the two given types are equal. @@ -29,7 +30,7 @@ type Includes = export type IsEqual = [A, B] extends [B, A] ? [A, B] extends [object, object] - ? _IsEqual, Simplify> + ? _IsEqual, SimplifyDeep> : _IsEqual : false; diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 1d31af85c..eaf49ba2b 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -86,9 +86,8 @@ expectType(equalTupleIntersectionToBeNeverAndNeverExpanded); declare const equalTupleIntersectionAndTuple: IsEqual<[{a: 1}] & [{a: 1}], [{a: 1}]>; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType(equalTupleIntersectionAndTuple); -// Distinct whether an object is merged by `&` or via `Simplify`, to ensure Branded Types are handled strictly. -export type IntersectionMerge = Except & {__brand: 'tag'}; -type SampleTuple = [0, 1, 2]; - +// Ensure `{a: t; b: s}` is equal to `{a: t} & {b: s}` expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); -expectType({} as IsEqual>, Merge, IntersectionMerge>>); +expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); +expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); From b887c8ce3ea66ecf26530a9733d67bd08f23e9f2 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 03:08:19 +0900 Subject: [PATCH 03/31] refactor: remoe unused imports --- test-d/is-equal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index eaf49ba2b..879a9a518 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {IsEqual, TupleOf, Merge, Simplify, Except} from '../index.d.ts'; +import type {IsEqual, TupleOf} from '../index.d.ts'; expectType({} as IsEqual); expectType({} as IsEqual<1, 1>); From df808ce4bd8888044691caa0828ee21776c2d2a5 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 03:29:16 +0900 Subject: [PATCH 04/31] refactor: remove unused import --- source/is-equal.d.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index b752d0a5c..0a37ed895 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,4 +1,3 @@ -import type {Simplify} from './simplify.d.ts'; import type {SimplifyDeep} from './simplify-deep.d.ts'; /** From b213bb7c439f26c1d57f1d6f6a0dd58bab47bf40 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 03:33:12 +0900 Subject: [PATCH 05/31] revert: `test-d/is-equal.ts` --- test-d/is-equal.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 879a9a518..cb27eb37b 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -86,6 +86,10 @@ expectType(equalTupleIntersectionToBeNeverAndNeverExpanded); declare const equalTupleIntersectionAndTuple: IsEqual<[{a: 1}] & [{a: 1}], [{a: 1}]>; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType(equalTupleIntersectionAndTuple); +// Test for Issue https://github.com/sindresorhus/type-fest/issues/1305 +type Assignability> = any; +type TestAssignability = Assignability; + // Ensure `{a: t; b: s}` is equal to `{a: t} & {b: s}` expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); From 4d8773c72a0289366dfc5b16cb8a1e4a3fc1c4fb Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 05:54:51 +0900 Subject: [PATCH 06/31] fix: define `UniqueUnionDeep` and `UniqueUnion`, to fix `IsEqual` returns `true` with intersection object and expanded object --- source/internal/type.d.ts | 79 +++++++++++++++++++++++++++++++++++++++ source/is-equal.d.ts | 3 +- test-d/is-equal.ts | 6 +++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index c1f53a2b9..bfc064c7f 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -3,6 +3,8 @@ 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'; +import type {SimplifyDeep} from '../simplify-deep.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -161,4 +163,81 @@ export type IsExactOptionalPropertyTypesEnabled = [(string | undefined)?] extend ? false : true; +/** +Returns the last element of a union type; otherwise `never` if `never` passed. +Note that this is non-deterministic because the order of union type is not guaranteed. + +@see https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468375328 + +This can be used to implement a recursive type function that accepts a union type. +It can detect a termination case using {@link IsNever `IsNever`}. + +@example +``` +type RecursionType = + LastOfUnion extends infer L + ? IsNever extends false + ? RecursionType, [...R, L]> + : R + : never; + +type RecursionTest = RecursionType; +//=> [string, number] +``` + +@example +``` +export type UnionToTuple> = + IsNever extends false + ? [...UnionToTuple>, L] + : []; +``` + +@example +``` +type Last = LastOfUnion<1 | 2 | 3>; +//=> 3 + +type LastNever = LastOfUnion; +//=> never +``` +*/ +export type LastOfUnion = + IsNever extends true + ? never + : UnionToIntersection T : never> extends () => (infer R) + ? R + : never; + +/** +In TypeScript, `{a: T}` and `{a: T} | {a: T}` are assignable mutually but automatically simplified. +And it disturbs a calculation of `IsEqual`. + +@example +``` +type NT = _IsEqual<{z: {a: 0}}, {z: {a: 0} | {a: 0}}>; // => false +``` + +`UniqueUnionDeep` is a helper type function: removes a duplicated type and keeps the other types. + +@example +``` +type UniqueUnionDeepTest = UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>; // => {z: {a: {aa: 0}} | {b: 0}; x: '1'} +type UniqueUnionDeepKeepDistributionTest = UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '1'}>; // => {z: {a: 0}; x: '1'} | {z: {a: 0}; x: '1'} +``` + +To remove delayed intersection, use `SimplifyDeep`. +And use `SimplifyDeep>>` if eliminating duplicated union and intersection. +*/ +export type UniqueUnionDeep = SimplifyDeep<_UniqueUnionDeep>; +type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? UniqueUnion<_UniqueUnionDeep> : U[K]}; + +export type UniqueUnion = _UniqueUnion; +type _UniqueUnion = + LastOfUnion extends infer K + ? [K] extends [never] + ? R + : _UniqueUnion, K extends R ? R : R | K> + : never; + export {}; diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 0a37ed895..cba5126f3 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,4 +1,5 @@ import type {SimplifyDeep} from './simplify-deep.d.ts'; +import type {UniqueUnionDeep} from './internal/type.d.ts'; /** Returns a boolean for whether the two given types are equal. @@ -29,7 +30,7 @@ type Includes = export type IsEqual = [A, B] extends [B, A] ? [A, B] extends [object, object] - ? _IsEqual, SimplifyDeep> + ? _IsEqual>, SimplifyDeep>> : _IsEqual : false; diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index cb27eb37b..36228c6f3 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -95,3 +95,9 @@ expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); + +// Ensure `{a: t}` is equal to `{a: t} | {a: t}` +expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{readonly aa: {a: 0} & ({b: 0} | {b: 0})}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From fb14a98d577d543ba3eebab454f28fda5a0b5b5f Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 06:26:16 +0900 Subject: [PATCH 07/31] doc: update `UniqueUnionDeep` comment --- source/internal/type.d.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index bfc064c7f..d7b4beae6 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -218,16 +218,17 @@ And it disturbs a calculation of `IsEqual`. type NT = _IsEqual<{z: {a: 0}}, {z: {a: 0} | {a: 0}}>; // => false ``` -`UniqueUnionDeep` is a helper type function: removes a duplicated type and keeps the other types. +`UniqueUnionDeep` is a helper type function: removes a duplicated type and keeps the other types recursively. +But union distribution also works as usual outside of objects. @example ``` type UniqueUnionDeepTest = UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>; // => {z: {a: {aa: 0}} | {b: 0}; x: '1'} -type UniqueUnionDeepKeepDistributionTest = UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '1'}>; // => {z: {a: 0}; x: '1'} | {z: {a: 0}; x: '1'} +type UniqueUnionDeepKeepDistributionTest = UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '2'}>; // => {z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'} ``` To remove delayed intersection, use `SimplifyDeep`. -And use `SimplifyDeep>>` if eliminating duplicated union and intersection. +And use `SimplifyDeep>` if eliminating duplicated union and intersection. */ export type UniqueUnionDeep = SimplifyDeep<_UniqueUnionDeep>; type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? UniqueUnion<_UniqueUnionDeep> : U[K]}; From 7f0162f49c92065ef757d797e08589bfb6beddb2 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 28 Jan 2026 06:33:50 +0900 Subject: [PATCH 08/31] doc: update `test-d/is-equal.ts` comment --- test-d/is-equal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 36228c6f3..62a958b0c 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -96,7 +96,7 @@ expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); -// Ensure `{a: t}` is equal to `{a: t} | {a: t}` +// Ensure `{a: t} | {a: t}` is equal to `{a: t}` expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From ba7ccab738eeaebaf4987b4c83c8750409714302 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 29 Jan 2026 03:00:03 +0900 Subject: [PATCH 09/31] test: add tests --- test-d/is-equal.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 62a958b0c..600428454 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -90,14 +90,20 @@ expectType(equalTupleIntersectionAndTuple); type Assignability> = any; type TestAssignability = Assignability; -// Ensure `{a: t; b: s}` is equal to `{a: t} & {b: s}` +// Ensure `{a: t; b: s}` === `{a: t} & {b: s}`, not equal to `{a: u} & {b: v}` if `u` !== `t` or `v` !== `s`. expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); -expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); -expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); +expectType({} as IsEqual<{a: 1} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{aa: {a: {x: 1} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); -// Ensure `{a: t} | {a: t}` is equal to `{a: t}` +// Ensure `{a: t} | {a: t}` === `{a: t}` expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{readonly aa: {a: 0} & ({b: 0} | {b: 0})}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `readonly key` should not be equal to `key` whether recursively or not. +expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); +expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {readonly a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 2cc3a260bb451ab5d4b963851288da93d58786a1 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 29 Jan 2026 03:00:20 +0900 Subject: [PATCH 10/31] update: add `UniqueUnionDeep` to define `IsEqual` to strictly get `A|A` is `A` --- source/internal/type.d.ts | 94 +++++++++++++++++++++++++++- source/is-equal.d.ts | 13 +++- test-d/internal/match-or-never.ts | 19 ++++++ test-d/internal/unique-exclude.ts | 28 +++++++++ test-d/internal/unique-union-deep.ts | 33 ++++++++++ test-d/internal/unique-union.ts | 27 ++++++++ 6 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 test-d/internal/match-or-never.ts create mode 100644 test-d/internal/unique-exclude.ts create mode 100644 test-d/internal/unique-union-deep.ts create mode 100644 test-d/internal/unique-union.ts diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index d7b4beae6..e88510ac5 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -230,15 +230,103 @@ type UniqueUnionDeepKeepDistributionTest = UniqueUnionDeep<{z: {a: 0} | {a: 0}; To remove delayed intersection, use `SimplifyDeep`. And use `SimplifyDeep>` if eliminating duplicated union and intersection. */ -export type UniqueUnionDeep = SimplifyDeep<_UniqueUnionDeep>; -type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? UniqueUnion<_UniqueUnionDeep> : U[K]}; +export type UniqueUnionDeep = U extends object ? SimplifyDeep<_UniqueUnionDeep> : U; +type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? UniqueUnion<_UniqueUnionDeep> : U[K]}; +/** +The flat version of `UniqueUnionDeep`. +*/ export type UniqueUnion = _UniqueUnion; type _UniqueUnion = LastOfUnion extends infer K ? [K] extends [never] ? R - : _UniqueUnion, K extends R ? R : R | K> + : _UniqueUnion< + UniqueExclude, + (() => G extends K & G | G ? 1 : 2) extends + (() => G extends R & G | G ? 1 : 2) + ? [R, unknown] extends [never, K] + ? K + : R + : R | K> : never; +/** +TypeScript's built-in `Exclude` and `ExcludeStrict` in `type-fest` don't distinguish kinds of keys of objects. + +@example +``` +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 +``` + +This `UniqueExclude` keeps the union objects element if the keys of the first and the second aren't identical. + +@example +``` +type ExcludeNever = UniqueExclude<{a: 0} | {a: 0} | {readonly a: 0}, never>; // => {a: 0} | {a: 0} | {readonly a: 0} +type ExcludeReadonlyKey = UniqueExclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>; // => {a: 0} +type ExcludeKey = UniqueExclude<{readonly a: 0}, {a: 0}>; // => {readonly a: 0} +type ExcludeReadonly = UniqueExclude<{readonly a: 0}, {readonly a: 0}>; // => {readonly a: 0} +type ExcludeSubType = UniqueExclude<0 | 1 | number, 1>; // => number +type ExcludeAllSet = UniqueExclude<0 | 1 | number, number>; // => never +type ExcludeFromUnknown = UniqueExclude; // => unknown +type ExcludeFromUnknownArray = UniqueExclude; // => unknown[] +``` +*/ +export type UniqueExclude = + UnionU extends unknown // Only for union distribution. + ? MatchOrNever + : never; + +/** +Return `never` if the 1st and the 2nd arguments are mutually identical. +Return the 1st if not. +(But there's a limitation about union/intersection type. See `MatchOrNever` or `_IsEqual` in `source/is-equal.d.ts` doc.) + +@example +``` +type A = MatchOrNever; // => string | number +type B = MatchOrNever; // => never +type C = MatchOrNever; // => string | number +type D = MatchOrNever; // => 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 code base. + +@example +``` +type E = MatchOrNever; // => unknown +type F = MatchOrNever; // => never +type G = MatchOrNever; // => never +type H = MatchOrNever; // => 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 +``` +*/ +export type MatchOrNever = + [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 extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) + ? never + : A; + export {}; diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index cba5126f3..2a562c0d5 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -34,7 +34,18 @@ export type IsEqual = : _IsEqual : false; -// This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. +/** +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 IDUnionIsTrue = _IsEqual<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; // true +type RecurivelyIDUnionIsFalse = _IsEqual<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; // => false +``` + +This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. +*/ type _IsEqual = (() => G extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) diff --git a/test-d/internal/match-or-never.ts b/test-d/internal/match-or-never.ts new file mode 100644 index 000000000..ba873fa61 --- /dev/null +++ b/test-d/internal/match-or-never.ts @@ -0,0 +1,19 @@ +import {expectType} from 'tsd'; +import type {MatchOrNever} from '../../source/internal/index.d.ts'; + +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); + +expectType<{readonly a: 0}>({} as MatchOrNever<{readonly a: 0}, {a: 0}>); +expectType<{a: 0}>({} as MatchOrNever<{a: 0}, {readonly a: 0}>); + +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); +expectType({} as MatchOrNever); + +expectType({} as MatchOrNever<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type A = {a: {b: 0} | {b: 0}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as MatchOrNever); diff --git a/test-d/internal/unique-exclude.ts b/test-d/internal/unique-exclude.ts new file mode 100644 index 000000000..e5822ca4e --- /dev/null +++ b/test-d/internal/unique-exclude.ts @@ -0,0 +1,28 @@ +import {expectType} from 'tsd'; +import type {UniqueExclude} from '../../source/internal/type.d.ts'; + +// `{readonly a: t}` should not be equal to `{a: t}` because of assignability. +expectType<{a: 0}>({} as UniqueExclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>); +expectType<{readonly a: 0}>({} as UniqueExclude<{readonly a: 0}, {a: 0}>); + +expectType({} as UniqueExclude<{readonly a: 0}, {readonly a: 0}>); +expectType({} as UniqueExclude<0 | 1 | number, 1>); +expectType({} as UniqueExclude<0 | 1 | number, number>); + +// `unknown` cannot be excluded like `unknown\T` in any cases. +expectType({} as UniqueExclude); +expectType<[unknown]>({} as UniqueExclude<[unknown], [number]>); +expectType({} as UniqueExclude); +expectType<{a: unknown}>({} as UniqueExclude<{a: unknown}, {a: number}>); + +// `unknown` and `any` exclude themselves. +expectType({} as UniqueExclude); +expectType({} as UniqueExclude); +expectType({} as UniqueExclude); +expectType({} as UniqueExclude); + +// `unknown` and `any` don't exclude other types. +expectType({} as UniqueExclude); +expectType({} as UniqueExclude); + +expectType({} as UniqueExclude); diff --git a/test-d/internal/unique-union-deep.ts b/test-d/internal/unique-union-deep.ts new file mode 100644 index 000000000..f80f1ab7b --- /dev/null +++ b/test-d/internal/unique-union-deep.ts @@ -0,0 +1,33 @@ +import {expectType} from 'tsd'; +import type {UniqueUnionDeep} from '../../source/internal/type.d.ts'; + +// The returns of `UniqueUnionDeep` are expected to be equal to `UniqueUnion`, if there's no recursive object type in the passed union type. +// That's why those tests are pasted from 'test-d/internal/union-unique.ts'. + +expectType({} as UniqueUnionDeep); +expectType({} as UniqueUnionDeep); +expectType<{a: 0}>({} as UniqueUnionDeep<{a: 0}>); +expectType({} as UniqueUnionDeep); +expectType({} as UniqueUnionDeep); +expectType<[unknown]>({} as UniqueUnionDeep<[unknown]>); +expectType<[any]>({} as UniqueUnionDeep<[any]>); + +expectType<{a: 0} | {a: 1}>({} as UniqueUnionDeep<{a: 0} | {a: 0} | {a: 0} | {a: 1}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `{a: t}` isn't excluded by `{a: T}` even if `T` includes `t`. +expectType<{a: 0} | {a: 1} | {a: number}>({} as UniqueUnionDeep<{a: 0} | {a: 0} | {a: 0} | {a: 1} | {a: number}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `readonly`, `optional`, both, and general object key shouldn't be mutually equal. +expectType<{a: number} | {readonly a: number} | {a?: number} | {readonly a?: number}>({} as UniqueUnionDeep<{a: number} | {readonly a: number} | {a?: number} | {readonly a?: number} | {a: number} | {readonly a: number} | {a?: number} | {readonly a?: number}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Empty tuple isn't removed by `T[]`. +expectType<[0, 1, 2] | [0, 1] | [] | number[]>({} as UniqueUnionDeep<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, 1] | [] | number[]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `T[]` doesn't delete tuples of `[t, ...t]` even if `T` includes `t`. +expectType<[0, 1, 2] | ['0', unknown] | [0, unknown]>({} as UniqueUnionDeep<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, unknown] | ['0', unknown]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `[u, ...u]` doesn't delete tuple of `[v, ...y]` even if `u` includes `v`. +expectType<[0, 1, 2] | ['0', unknown] | [0, unknown] | number[]>({} as UniqueUnionDeep<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, unknown] | ['0', unknown] | number[]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +expectType<{z: {a: {aa: 0}} | {b: 0}; x: '1'}>({} as UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType<{z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'}>({} as UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '2'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents diff --git a/test-d/internal/unique-union.ts b/test-d/internal/unique-union.ts new file mode 100644 index 000000000..eecf9af05 --- /dev/null +++ b/test-d/internal/unique-union.ts @@ -0,0 +1,27 @@ +import {expectType} from 'tsd'; +import type {UniqueUnion} from '../../source/internal/type.d.ts'; + +expectType({} as UniqueUnion); +expectType({} as UniqueUnion); +expectType<{a: 0}>({} as UniqueUnion<{a: 0}>); +expectType({} as UniqueUnion); +expectType({} as UniqueUnion); +expectType<[unknown]>({} as UniqueUnion<[unknown]>); +expectType<[any]>({} as UniqueUnion<[any]>); + +expectType<{a: 0} | {a: 1}>({} as UniqueUnion<{a: 0} | {a: 0} | {a: 0} | {a: 1}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `{a: t}` isn't excluded by `{a: T}` even if `T` includes `t`. +expectType<{a: 0} | {a: 1} | {a: number}>({} as UniqueUnion<{a: 0} | {a: 0} | {a: 0} | {a: 1} | {a: number}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `readonly`, `optional`, both, and general object key shouldn't be mutually equal. +expectType<{a: number} | {readonly a: number} | {a?: number} | {readonly a?: number}>({} as UniqueUnion<{a: number} | {readonly a: number} | {a?: number} | {readonly a?: number} | {a: number} | {readonly a: number} | {a?: number} | {readonly a?: number}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Empty tuple isn't removed by `T[]`. +expectType<[0, 1, 2] | [0, 1] | [] | number[]>({} as UniqueUnion<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, 1] | [] | number[]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `T[]` doesn't delete tuples of `[t, ...t]` even if `T` includes `t`. +expectType<[0, 1, 2] | ['0', unknown] | [0, unknown]>({} as UniqueUnion<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, unknown] | ['0', unknown]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `[u, ...u]` doesn't delete tuples of `[v, ...y]` even if `u` includes `v`. +expectType<[0, 1, 2] | ['0', unknown] | [0, unknown] | number[]>({} as UniqueUnion<[0, 1, 2] | [0, 1, 2] | [0, 1, 2] | [0, unknown] | ['0', unknown] | number[]>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 590587cd7e1a849e335444c6ec4be4543bbb2f34 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Mon, 2 Feb 2026 04:42:24 +0900 Subject: [PATCH 11/31] fix: rename `ExcludeExactly` from `UniqueExclude`, update for a case 2nd argument is a union type --- source/internal/type.d.ts | 37 ++++++++++++++++++---------- test-d/internal/exclude-exactly.ts | 39 ++++++++++++++++++++++++++++++ test-d/internal/unique-exclude.ts | 28 --------------------- 3 files changed, 63 insertions(+), 41 deletions(-) create mode 100644 test-d/internal/exclude-exactly.ts delete mode 100644 test-d/internal/unique-exclude.ts diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index e88510ac5..ae2383dd5 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -1,5 +1,6 @@ import type {If} from '../if.d.ts'; import type {IsAny} from '../is-any.d.ts'; +import type {IsUnknown} from '../is-unknown.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'; @@ -242,7 +243,7 @@ type _UniqueUnion = ? [K] extends [never] ? R : _UniqueUnion< - UniqueExclude, + ExcludeExactly, (() => G extends K & G | G ? 1 : 2) extends (() => G extends R & G | G ? 1 : 2) ? [R, unknown] extends [never, K] @@ -260,24 +261,34 @@ type NeverReturned_0 = Exclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>; // => type NeverReturned_1 = ExcludeStrict<{a: 0} | {readonly a: 0}, {readonly a: 0}>; // => never ``` -This `UniqueExclude` keeps the union objects element if the keys of the first and the second aren't identical. +This `ExcludeExactly` keeps the union objects element if the keys of the first and the second aren't identical. @example ``` -type ExcludeNever = UniqueExclude<{a: 0} | {a: 0} | {readonly a: 0}, never>; // => {a: 0} | {a: 0} | {readonly a: 0} -type ExcludeReadonlyKey = UniqueExclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>; // => {a: 0} -type ExcludeKey = UniqueExclude<{readonly a: 0}, {a: 0}>; // => {readonly a: 0} -type ExcludeReadonly = UniqueExclude<{readonly a: 0}, {readonly a: 0}>; // => {readonly a: 0} -type ExcludeSubType = UniqueExclude<0 | 1 | number, 1>; // => number -type ExcludeAllSet = UniqueExclude<0 | 1 | number, number>; // => never -type ExcludeFromUnknown = UniqueExclude; // => unknown -type ExcludeFromUnknownArray = UniqueExclude; // => unknown[] +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}>; // => {readonly a: 0} +type ExcludeSubType = ExcludeExactly<0 | 1 | number, 1>; // => number +type ExcludeAllSet = ExcludeExactly<0 | 1 | number, number>; // => never +type ExcludeFromUnknown = ExcludeExactly; // => unknown +type ExcludeFromUnknownArray = ExcludeExactly; // => unknown[] ``` */ -export type UniqueExclude = - UnionU extends unknown // Only for union distribution. - ? MatchOrNever +export type ExcludeExactly = + LastOfUnion extends infer D + ? true extends IsNever + ? UnionU + : ExcludeExactly<_ExcludeExactly, _ExcludeExactly> : never; +type _ExcludeExactly = + true extends IsAny + ? never + : true extends IsUnknown + ? never + : UnionU extends unknown // Only for union distribution. + ? MatchOrNever + : never; /** Return `never` if the 1st and the 2nd arguments are mutually identical. diff --git a/test-d/internal/exclude-exactly.ts b/test-d/internal/exclude-exactly.ts new file mode 100644 index 000000000..3443ccbe6 --- /dev/null +++ b/test-d/internal/exclude-exactly.ts @@ -0,0 +1,39 @@ +import {expectType} from 'tsd'; +import type {ExcludeExactly} from '../../source/internal/type.d.ts'; + +expectType({} as ExcludeExactly<0 | 1 | number, '1'>); +expectType({} as ExcludeExactly<0 | 1 | number, number>); +expectType({} as ExcludeExactly<'0' | '1' | string, '1'>); +expectType({} 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({} as ExcludeExactly<{readonly a: 0}, {readonly a: 0}>); + +// `never` does nothing. +expectType<0 | 1 | 2>({} as ExcludeExactly<0 | 1 | 2, never>); +expectType({} as ExcludeExactly); + +// `unknown` cannot be excluded like `unknown\T` in any cases. +expectType({} as ExcludeExactly); +expectType<[unknown]>({} as ExcludeExactly<[unknown], [number]>); +expectType({} as ExcludeExactly); +expectType<{a: unknown}>({} as ExcludeExactly<{a: unknown}, {a: number}>); +expectType({} as ExcludeExactly); + +// `unknown` and `any` exclude themselves. +expectType({} as ExcludeExactly); +expectType({} as ExcludeExactly); +expectType({} as ExcludeExactly); +expectType({} as ExcludeExactly); + +// `unknown` and `any` exclude other types. +expectType({} as ExcludeExactly); +expectType({} as ExcludeExactly); + +// Union +expectType<2>({} as ExcludeExactly<0 | 1 | 2, 0 | 1>); +expectType({} 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({} as ExcludeExactly<{a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}, {a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}>); diff --git a/test-d/internal/unique-exclude.ts b/test-d/internal/unique-exclude.ts deleted file mode 100644 index e5822ca4e..000000000 --- a/test-d/internal/unique-exclude.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {expectType} from 'tsd'; -import type {UniqueExclude} from '../../source/internal/type.d.ts'; - -// `{readonly a: t}` should not be equal to `{a: t}` because of assignability. -expectType<{a: 0}>({} as UniqueExclude<{a: 0} | {readonly a: 0}, {readonly a: 0}>); -expectType<{readonly a: 0}>({} as UniqueExclude<{readonly a: 0}, {a: 0}>); - -expectType({} as UniqueExclude<{readonly a: 0}, {readonly a: 0}>); -expectType({} as UniqueExclude<0 | 1 | number, 1>); -expectType({} as UniqueExclude<0 | 1 | number, number>); - -// `unknown` cannot be excluded like `unknown\T` in any cases. -expectType({} as UniqueExclude); -expectType<[unknown]>({} as UniqueExclude<[unknown], [number]>); -expectType({} as UniqueExclude); -expectType<{a: unknown}>({} as UniqueExclude<{a: unknown}, {a: number}>); - -// `unknown` and `any` exclude themselves. -expectType({} as UniqueExclude); -expectType({} as UniqueExclude); -expectType({} as UniqueExclude); -expectType({} as UniqueExclude); - -// `unknown` and `any` don't exclude other types. -expectType({} as UniqueExclude); -expectType({} as UniqueExclude); - -expectType({} as UniqueExclude); From 2f1d991cc40a6525755c4644bd4328397f500f8e Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Wed, 4 Feb 2026 01:30:48 +0900 Subject: [PATCH 12/31] build: trigger CI --- source/paths.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/paths.d.ts b/source/paths.d.ts index 07f5eab9b..aaebe6da6 100644 --- a/source/paths.d.ts +++ b/source/paths.d.ts @@ -77,10 +77,10 @@ export type PathsOptions = { }; type AllPaths = Paths; - //=> 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' + //=> 'id' | 'author' | 'author.name' | 'author.name.first' | 'author.name.last' | 'author.id' type LeafPaths = Paths; - //=> 'id' | 'author.id' | 'author.name.first' | 'author.name.last' + //=> 'id' | 'author.name.first' | 'author.name.last' | 'author.id' ``` @example @@ -127,7 +127,7 @@ export type PathsOptions = { //=> 'id' | 'author' type DepthOne = Paths; - //=> 'author.id' | 'author.name' + //=> 'author.name' | 'author.id' type DepthTwo = Paths; //=> 'author.name.first' | 'author.name.last' From d02b3532d0079524975fa44d5a3ba52e9a48dab0 Mon Sep 17 00:00:00 2001 From: Som Shekhar Mukherjee Date: Wed, 4 Feb 2026 19:20:48 +0530 Subject: [PATCH 13/31] revert `IsEqual` and `Paths` changes --- source/is-equal.d.ts | 24 +++++------------------- source/paths.d.ts | 6 +++--- test-d/is-equal.ts | 18 ------------------ 3 files changed, 8 insertions(+), 40 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 2a562c0d5..306b4687e 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,6 +1,3 @@ -import type {SimplifyDeep} from './simplify-deep.d.ts'; -import type {UniqueUnionDeep} from './internal/type.d.ts'; - /** Returns a boolean for whether the two given types are equal. @@ -28,24 +25,13 @@ type Includes = @category Utilities */ export type IsEqual = - [A, B] extends [B, A] - ? [A, B] extends [object, object] - ? _IsEqual>, SimplifyDeep>> - : _IsEqual + [A] extends [B] + ? [B] extends [A] + ? _IsEqual + : false : false; -/** -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 IDUnionIsTrue = _IsEqual<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; // true -type RecurivelyIDUnionIsFalse = _IsEqual<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; // => false -``` - -This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. -*/ +// This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. type _IsEqual = (() => G extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) diff --git a/source/paths.d.ts b/source/paths.d.ts index aaebe6da6..07f5eab9b 100644 --- a/source/paths.d.ts +++ b/source/paths.d.ts @@ -77,10 +77,10 @@ export type PathsOptions = { }; type AllPaths = Paths; - //=> 'id' | 'author' | 'author.name' | 'author.name.first' | 'author.name.last' | 'author.id' + //=> 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' type LeafPaths = Paths; - //=> 'id' | 'author.name.first' | 'author.name.last' | 'author.id' + //=> 'id' | 'author.id' | 'author.name.first' | 'author.name.last' ``` @example @@ -127,7 +127,7 @@ export type PathsOptions = { //=> 'id' | 'author' type DepthOne = Paths; - //=> 'author.name' | 'author.id' + //=> 'author.id' | 'author.name' type DepthTwo = Paths; //=> 'author.name.first' | 'author.name.last' diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 600428454..c7856c540 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -89,21 +89,3 @@ expectType(equalTupleIntersectionAndTuple); // Test for Issue https://github.com/sindresorhus/type-fest/issues/1305 type Assignability> = any; type TestAssignability = Assignability; - -// Ensure `{a: t; b: s}` === `{a: t} & {b: s}`, not equal to `{a: u} & {b: v}` if `u` !== `t` or `v` !== `s`. -expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); -expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); -expectType({} as IsEqual<{a: 1} & {b: 0}, {a: 0; b: 0}>); -expectType({} as IsEqual<{aa: {a: {x: 1} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); - -// Ensure `{a: t} | {a: t}` === `{a: t}` -expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -expectType({} as IsEqual<{readonly aa: {a: 0} & ({b: 0} | {b: 0})}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents - -// `readonly key` should not be equal to `key` whether recursively or not. -expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); -expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); -expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -expectType({} as IsEqual<{aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {readonly a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From c6d420651579ae19f630955232b80c72ef5ccc2f Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 00:45:25 +0900 Subject: [PATCH 14/31] revert: re-revert d02b353, `source/is-equal.d.ts` and `test-d/is-equal.ts` --- source/is-equal.d.ts | 24 +++++++++++++++++++----- test-d/is-equal.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 306b4687e..2a562c0d5 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,3 +1,6 @@ +import type {SimplifyDeep} from './simplify-deep.d.ts'; +import type {UniqueUnionDeep} from './internal/type.d.ts'; + /** Returns a boolean for whether the two given types are equal. @@ -25,13 +28,24 @@ type Includes = @category Utilities */ export type IsEqual = - [A] extends [B] - ? [B] extends [A] - ? _IsEqual - : false + [A, B] extends [B, A] + ? [A, B] extends [object, object] + ? _IsEqual>, SimplifyDeep>> + : _IsEqual : false; -// This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. +/** +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 IDUnionIsTrue = _IsEqual<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; // true +type RecurivelyIDUnionIsFalse = _IsEqual<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; // => false +``` + +This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. +*/ type _IsEqual = (() => G extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index c7856c540..600428454 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -89,3 +89,21 @@ expectType(equalTupleIntersectionAndTuple); // Test for Issue https://github.com/sindresorhus/type-fest/issues/1305 type Assignability> = any; type TestAssignability = Assignability; + +// Ensure `{a: t; b: s}` === `{a: t} & {b: s}`, not equal to `{a: u} & {b: v}` if `u` !== `t` or `v` !== `s`. +expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); +expectType({} as IsEqual<{a: 1} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{aa: {a: {x: 1} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); + +// Ensure `{a: t} | {a: t}` === `{a: t}` +expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{readonly aa: {a: 0} & ({b: 0} | {b: 0})}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// `readonly key` should not be equal to `key` whether recursively or not. +expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); +expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {readonly a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 687e6ed54def3fdae5e273fe346b1ccdedc8c5b4 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 23:37:09 +0900 Subject: [PATCH 15/31] add: `LastOfUnion`, return a type of an union-type (order is not guaranteed) --- index.d.ts | 1 + readme.md | 1 + source/last-of-union.d.ts | 41 +++++++++++++++++++++++++++++++++++++++ test-d/last-of-union.ts | 10 ++++++++++ 4 files changed, 53 insertions(+) create mode 100644 source/last-of-union.d.ts create mode 100644 test-d/last-of-union.ts diff --git a/index.d.ts b/index.d.ts index 8a9926d8d..f859bce03 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; diff --git a/readme.md b/readme.md index 47e9fee30..55d202543 100644 --- a/readme.md +++ b/readme.md @@ -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 type picked in an union-type. Order is not guaranteed. ### Type Guard diff --git a/source/last-of-union.d.ts b/source/last-of-union.d.ts new file mode 100644 index 000000000..7d2dfa8d7 --- /dev/null +++ b/source/last-of-union.d.ts @@ -0,0 +1,41 @@ +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; otherwise `never` if `never` passed. +Note that this is non-deterministic because the order of union type is not guaranteed. + +@see https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468375328 + +This can be used to implement a recursive type function that accepts a union type. +It can detect a termination case using {@link IsNever `IsNever`}. + +@example +``` +import type {LastOfUnion, ExcludeExactly, IsNever} from 'type-fest'; + +export type UnionToTuple> = + IsNever extends false + ? [...UnionToTuple>, L] + : []; +``` + +@example +``` +import type {LastOfUnion} from 'type-fest'; + +type Last = LastOfUnion<1 | 2 | 3>; +//=> 3 + +type LastNever = LastOfUnion; +//=> never +``` +*/ +export type LastOfUnion = + true extends IsNever + ? never + : UnionToIntersection T : never> extends () => (infer R) + ? R + : never; + +export {}; diff --git a/test-d/last-of-union.ts b/test-d/last-of-union.ts new file mode 100644 index 000000000..c3b904267 --- /dev/null +++ b/test-d/last-of-union.ts @@ -0,0 +1,10 @@ +import {expectType} from 'tsd'; +import type {LastOfUnion} from '../index.d.ts'; + +// `LastOfUnion` distinguish between different modifiers. +type UnionType = {a: 0} | {b: 0} | {a?: 0} | {readonly a?: 0} | {readonly a: 0}; +expectType({} as LastOfUnion extends UnionType ? true : false); +expectType({} as UnionType extends LastOfUnion ? true : false); + +// `never` act as a termination condition with `IsNever`. +expectType({} as LastOfUnion); From 631a49f3b1c614cb0ead2fbcb9ba14678974ae55 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 23:38:25 +0900 Subject: [PATCH 16/31] refactor: `UnionToTuple`, import `LastOfUnion` instead of define --- source/union-to-tuple.d.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/source/union-to-tuple.d.ts b/source/union-to-tuple.d.ts index 5a6d13ae2..98ab73cf3 100644 --- a/source/union-to-tuple.d.ts +++ b/source/union-to-tuple.d.ts @@ -1,19 +1,5 @@ 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 = -UnionToIntersection T : never> extends () => (infer R) - ? R - : never; +import type {LastOfUnion} from './last-of-union.d.ts'; /** Convert a union type into an unordered tuple type of its elements. From 755df635ccbb2220e21de70ab16c56a6952e9f75 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 23:43:30 +0900 Subject: [PATCH 17/31] Add `ExcludeExactly`, distinguish between different modifiers. --- index.d.ts | 1 + readme.md | 1 + source/exclude-exactly.d.ts | 98 ++++++++++++++++++++++++ test-d/{internal => }/exclude-exactly.ts | 2 +- test-d/internal/match-or-never.ts | 19 ----- 5 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 source/exclude-exactly.d.ts rename test-d/{internal => }/exclude-exactly.ts (96%) delete mode 100644 test-d/internal/match-or-never.ts diff --git a/index.d.ts b/index.d.ts index f859bce03..42afe12b5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -208,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 {}; diff --git a/readme.md b/readme.md index 55d202543..6cc0b5b87 100644 --- a/readme.md +++ b/readme.md @@ -320,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` that ensures every member of `U` can successfully extract something from `T`. - [`ExcludeStrict`](source/exclude-strict.d.ts) - A stricter version of `Exclude` that ensures every member of `U` can successfully exclude something from `T`. +- [`ExcludeExactly`](source/exclude-exactly.d.ts) - A stricter version of `Exclude` that ensures objects with different key modifiers are not considered identical. ## Declined types diff --git a/source/exclude-exactly.d.ts b/source/exclude-exactly.d.ts new file mode 100644 index 000000000..0fa751307 --- /dev/null +++ b/source/exclude-exactly.d.ts @@ -0,0 +1,98 @@ +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 1st and the 2nd arguments are mutually identical. +Return the 1st if not. +(But there's a limitation about union/intersection type. See `MatchOrNever` or `_IsEqual` in `source/is-equal.d.ts` doc.) + +@example +``` +type A = MatchOrNever; // => string | number +type B = MatchOrNever; // => never +type C = MatchOrNever; // => string | number +type D = MatchOrNever; // => 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 code base. + +@example +``` +type E = MatchOrNever; // => unknown +type F = MatchOrNever; // => never +type G = MatchOrNever; // => never +type H = MatchOrNever; // => 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 +``` +*/ +type MatchOrNever = + [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 extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) + ? never + : A; + +/** +TypeScript's built-in `Exclude` and `ExcludeStrict` in `type-fest` don't distinguish kinds of keys 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 +``` + +This `ExcludeExactly` keeps the union objects element if the keys of the first and the second aren't 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}>; // => {readonly a: 0} +type ExcludeSubType = ExcludeExactly<0 | 1 | number, 1>; // => number +type ExcludeAllSet = ExcludeExactly<0 | 1 | number, number>; // => never +type ExcludeFromUnknown = ExcludeExactly; // => unknown +type ExcludeFromUnknownArray = ExcludeExactly; // => unknown[] +``` +*/ +export type ExcludeExactly = + LastOfUnion extends infer D + ? true extends IsNever + ? UnionU + : ExcludeExactly<_ExcludeExactly, _ExcludeExactly> + : never; +type _ExcludeExactly = + true extends IsAny + ? never + : true extends IsUnknown + ? never + : UnionU extends unknown // Only for union distribution. + ? MatchOrNever + : never; + +export {}; diff --git a/test-d/internal/exclude-exactly.ts b/test-d/exclude-exactly.ts similarity index 96% rename from test-d/internal/exclude-exactly.ts rename to test-d/exclude-exactly.ts index 3443ccbe6..935a30c3b 100644 --- a/test-d/internal/exclude-exactly.ts +++ b/test-d/exclude-exactly.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import type {ExcludeExactly} from '../../source/internal/type.d.ts'; +import type {ExcludeExactly} from '../index.d.ts'; expectType({} as ExcludeExactly<0 | 1 | number, '1'>); expectType({} as ExcludeExactly<0 | 1 | number, number>); diff --git a/test-d/internal/match-or-never.ts b/test-d/internal/match-or-never.ts deleted file mode 100644 index ba873fa61..000000000 --- a/test-d/internal/match-or-never.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {expectType} from 'tsd'; -import type {MatchOrNever} from '../../source/internal/index.d.ts'; - -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); - -expectType<{readonly a: 0}>({} as MatchOrNever<{readonly a: 0}, {a: 0}>); -expectType<{a: 0}>({} as MatchOrNever<{a: 0}, {readonly a: 0}>); - -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); -expectType({} as MatchOrNever); - -expectType({} as MatchOrNever<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -type A = {a: {b: 0} | {b: 0}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -expectType({} as MatchOrNever); From 1f76fd524e51dd663851b9e33c75775d4de6d9fa Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 23:44:51 +0900 Subject: [PATCH 18/31] fix: `UnionToTuple`, use `ExcludeExactly`, improve performance. --- source/union-to-tuple.d.ts | 5 ++++- test-d/union-to-tuple.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/source/union-to-tuple.d.ts b/source/union-to-tuple.d.ts index 98ab73cf3..f9184f7d9 100644 --- a/source/union-to-tuple.d.ts +++ b/source/union-to-tuple.d.ts @@ -1,4 +1,5 @@ import type {IsNever} from './is-never.d.ts'; +import type {ExcludeExactly} from './exclude-exactly.d.ts'; import type {LastOfUnion} from './last-of-union.d.ts'; /** @@ -38,7 +39,9 @@ const petList = Object.keys(pets) as UnionToTuple; */ export type UnionToTuple> = IsNever extends false - ? [...UnionToTuple>, L] + ? ExcludeExactly extends infer E // Improve performance. + ? [...UnionToTuple, L] + : never // Unreachable. : []; export {}; diff --git a/test-d/union-to-tuple.ts b/test-d/union-to-tuple.ts index 12de3cf2b..1dcaec026 100644 --- a/test-d/union-to-tuple.ts +++ b/test-d/union-to-tuple.ts @@ -11,3 +11,6 @@ expectType({} as (1 | 2 | 3)); type Options2 = UnionToTuple; expectType({} as (1 | false | true)); + +type DifferentModifiers = {a: 0} | {readonly a: 0} | {a?: 0} | {readonly a?: 0}; +expectType[number]>({} as DifferentModifiers); From 7d990be57ad11309ae869196c1edd90eef84b796 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 5 Feb 2026 23:45:25 +0900 Subject: [PATCH 19/31] Refactor: `UniqueUnion`, use `UnionToTuple`, improve performance. --- source/internal/type.d.ts | 155 ++------------------------------------ 1 file changed, 5 insertions(+), 150 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index ae2383dd5..85336b2b9 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -1,11 +1,10 @@ import type {If} from '../if.d.ts'; import type {IsAny} from '../is-any.d.ts'; -import type {IsUnknown} from '../is-unknown.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'; import type {SimplifyDeep} from '../simplify-deep.d.ts'; +import type {UnionToTuple} from '../union-to-tuple.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -164,52 +163,6 @@ export type IsExactOptionalPropertyTypesEnabled = [(string | undefined)?] extend ? false : true; -/** -Returns the last element of a union type; otherwise `never` if `never` passed. -Note that this is non-deterministic because the order of union type is not guaranteed. - -@see https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468375328 - -This can be used to implement a recursive type function that accepts a union type. -It can detect a termination case using {@link IsNever `IsNever`}. - -@example -``` -type RecursionType = - LastOfUnion extends infer L - ? IsNever extends false - ? RecursionType, [...R, L]> - : R - : never; - -type RecursionTest = RecursionType; -//=> [string, number] -``` - -@example -``` -export type UnionToTuple> = - IsNever extends false - ? [...UnionToTuple>, L] - : []; -``` - -@example -``` -type Last = LastOfUnion<1 | 2 | 3>; -//=> 3 - -type LastNever = LastOfUnion; -//=> never -``` -*/ -export type LastOfUnion = - IsNever extends true - ? never - : UnionToIntersection T : never> extends () => (infer R) - ? R - : never; - /** In TypeScript, `{a: T}` and `{a: T} | {a: T}` are assignable mutually but automatically simplified. And it disturbs a calculation of `IsEqual`. @@ -237,107 +190,9 @@ type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? /** The flat version of `UniqueUnionDeep`. */ -export type UniqueUnion = _UniqueUnion; -type _UniqueUnion = - LastOfUnion extends infer K - ? [K] extends [never] - ? R - : _UniqueUnion< - ExcludeExactly, - (() => G extends K & G | G ? 1 : 2) extends - (() => G extends R & G | G ? 1 : 2) - ? [R, unknown] extends [never, K] - ? K - : R - : R | K> - : never; - -/** -TypeScript's built-in `Exclude` and `ExcludeStrict` in `type-fest` don't distinguish kinds of keys of objects. - -@example -``` -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 -``` - -This `ExcludeExactly` keeps the union objects element if the keys of the first and the second aren't identical. - -@example -``` -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}>; // => {readonly a: 0} -type ExcludeSubType = ExcludeExactly<0 | 1 | number, 1>; // => number -type ExcludeAllSet = ExcludeExactly<0 | 1 | number, number>; // => never -type ExcludeFromUnknown = ExcludeExactly; // => unknown -type ExcludeFromUnknownArray = ExcludeExactly; // => unknown[] -``` -*/ -export type ExcludeExactly = - LastOfUnion extends infer D - ? true extends IsNever - ? UnionU - : ExcludeExactly<_ExcludeExactly, _ExcludeExactly> - : never; -type _ExcludeExactly = - true extends IsAny - ? never - : true extends IsUnknown - ? never - : UnionU extends unknown // Only for union distribution. - ? MatchOrNever - : never; - -/** -Return `never` if the 1st and the 2nd arguments are mutually identical. -Return the 1st if not. -(But there's a limitation about union/intersection type. See `MatchOrNever` or `_IsEqual` in `source/is-equal.d.ts` doc.) - -@example -``` -type A = MatchOrNever; // => string | number -type B = MatchOrNever; // => never -type C = MatchOrNever; // => string | number -type D = MatchOrNever; // => 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 code base. - -@example -``` -type E = MatchOrNever; // => unknown -type F = MatchOrNever; // => never -type G = MatchOrNever; // => never -type H = MatchOrNever; // => 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 -``` -*/ -export type MatchOrNever = - [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 extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) - ? never - : A; +export type UniqueUnion = + UnionToTuple extends infer E extends readonly unknown[] // Improve performance. + ? E[number] + : never; // Unreachable. export {}; From 944a74b01e46e1f51eaaba99aa5eb569d42d3e5e Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 6 Feb 2026 00:14:39 +0900 Subject: [PATCH 20/31] temp: ignore arrow-doc. --- source/paths.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/paths.d.ts b/source/paths.d.ts index f2aa78d00..05f8b8f53 100644 --- a/source/paths.d.ts +++ b/source/paths.d.ts @@ -77,10 +77,10 @@ export type PathsOptions = { }; type AllPaths = Paths; - //=> 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' + // => 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' type LeafPaths = Paths; - //=> 'id' | 'author.id' | 'author.name.first' | 'author.name.last' + // => 'id' | 'author.id' | 'author.name.first' | 'author.name.last' ``` @example @@ -127,7 +127,7 @@ export type PathsOptions = { //=> 'id' | 'author' type DepthOne = Paths; - //=> 'author.id' | 'author.name' + // => 'author.id' | 'author.name' type DepthTwo = Paths; //=> 'author.name.first' | 'author.name.last' From a8c2695fd2291c3bdd304d0d063b75b7527ae3a4 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 6 Feb 2026 05:54:17 +0900 Subject: [PATCH 21/31] refactor: `IsEqual`, remove redundant `SimplifyDeep`, fix the doc. --- source/is-equal.d.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 2a562c0d5..0ea4812fd 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -1,4 +1,3 @@ -import type {SimplifyDeep} from './simplify-deep.d.ts'; import type {UniqueUnionDeep} from './internal/type.d.ts'; /** @@ -30,7 +29,7 @@ type Includes = export type IsEqual = [A, B] extends [B, A] ? [A, B] extends [object, object] - ? _IsEqual>, SimplifyDeep>> + ? _IsEqual, UniqueUnionDeep> : _IsEqual : false; @@ -40,8 +39,10 @@ e.g., `{a: 0} | {a: 0}` and/or `{a: 0} & {a: 0}` as `{a: 0}`. @example ``` -type IDUnionIsTrue = _IsEqual<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; // true -type RecurivelyIDUnionIsFalse = _IsEqual<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; // => false +type IDUnionIsTrue = _IsEqual<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; +//=> true +type RecurivelyIDUnionIsFalse = _IsEqual<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; +//=> false ``` This version fails the `equalWrappedTupleIntersectionToBeNeverAndNeverExpanded` test in `test-d/is-equal.ts`. From 18eb4af167c15be205d2f0b975dfa620306554d9 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sun, 8 Feb 2026 05:38:59 +0900 Subject: [PATCH 22/31] Revert "temp: ignore arrow-doc." This reverts commit 944a74b01e46e1f51eaaba99aa5eb569d42d3e5e. --- source/paths.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/paths.d.ts b/source/paths.d.ts index 6c1f65ab3..d0edfbaf9 100644 --- a/source/paths.d.ts +++ b/source/paths.d.ts @@ -77,10 +77,10 @@ export type PathsOptions = { }; type AllPaths = Paths; - // => 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' + //=> 'id' | 'author' | 'author.id' | 'author.name' | 'author.name.first' | 'author.name.last' type LeafPaths = Paths; - // => 'id' | 'author.id' | 'author.name.first' | 'author.name.last' + //=> 'id' | 'author.id' | 'author.name.first' | 'author.name.last' ``` @example @@ -127,7 +127,7 @@ export type PathsOptions = { //=> 'id' | 'author' type DepthOne = Paths; - // => 'author.id' | 'author.name' + //=> 'author.id' | 'author.name' type DepthTwo = Paths; //=> 'author.name.first' | 'author.name.last' From 42822837d2ce712c5ec2d0689eb00b5311007fa2 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 10 Feb 2026 15:52:33 +0700 Subject: [PATCH 23/31] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 6cc0b5b87..fd519c89b 100644 --- a/readme.md +++ b/readme.md @@ -191,7 +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 type picked in an union-type. Order is not guaranteed. +- [`LastOfUnion`](source/last-of-union.d.ts) - Return a type picked in an union-type. Order is not guaranteed. ### Type Guard From c5ade480b1143842b568e6ba2a23ffb0320e9ee2 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Tue, 10 Feb 2026 20:22:17 +0900 Subject: [PATCH 24/31] update: `UniqueUnionDeep`, add `IsEqual`'s lambda test cases --- source/internal/type.d.ts | 31 +++++++++++---- source/is-equal.d.ts | 4 +- test-d/is-equal.ts | 80 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 85336b2b9..0259ceb90 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -3,7 +3,6 @@ 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 {SimplifyDeep} from '../simplify-deep.d.ts'; import type {UnionToTuple} from '../union-to-tuple.d.ts'; /** @@ -177,15 +176,31 @@ But union distribution also works as usual outside of objects. @example ``` -type UniqueUnionDeepTest = UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>; // => {z: {a: {aa: 0}} | {b: 0}; x: '1'} -type UniqueUnionDeepKeepDistributionTest = UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '2'}>; // => {z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'} -``` +type UniqueUnionDeepTest = SimplifyDeep>; +//=> {z: {a: {aa: 0}} | {b: 0}; x: '1'} + +type UniqueUnionDeepKeepDistributionTest = SimplifyDeep>; +//=> {z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'} -To remove delayed intersection, use `SimplifyDeep`. -And use `SimplifyDeep>` if eliminating duplicated union and intersection. +type UniqueUnionDeepArguments = SimplifyDeep {b: number} | {b: number}>>; +//=> (a: {a: number} | {a: number}) => {b: number} | {b: number} + +type UniqueUnionDeepArgumentsDeep = SimplifyDeep {b: {b: number} | {b: number}}>>; +//=> (a: {a: number}) => {b: {b: number}} +``` */ -export type UniqueUnionDeep = U extends object ? SimplifyDeep<_UniqueUnionDeep> : U; -type _UniqueUnionDeep = {[K in keyof U]: U[K] extends object ? UniqueUnion<_UniqueUnionDeep> : U[K]}; +export type UniqueUnionDeep = + U extends Record + ? _UniqueUnionDeep + : U extends Lambda + ? (...args: _UniqueUnionDeep>) => (UniqueUnion<_UniqueUnionDeep>>) + : U; +/** +Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". +*/ +type _UniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; + +type Lambda = ((...args: any[]) => any); /** The flat version of `UniqueUnionDeep`. diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index 0ea4812fd..442164aa4 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -28,9 +28,7 @@ type Includes = */ export type IsEqual = [A, B] extends [B, A] - ? [A, B] extends [object, object] - ? _IsEqual, UniqueUnionDeep> - : _IsEqual + ? _IsEqual, UniqueUnionDeep> : false; /** diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 600428454..11f99c96b 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -42,6 +42,86 @@ expectType({} as IsEqual<[string], [string]>); expectType({} as IsEqual<[string], [string, number]>); expectType({} as IsEqual<[0, 1] | [0, 2], [0, 2]>); +// Lambda cases. +type SpecificNumericLambda = (value: {a: 1}) => {b: 1}; +type SpecificNumericLambda1 = (value: {a: 2}) => {b: 1}; +type NumberLambda = (value: {a: number}) => {b: number}; +type AnyLambda = (value: {a: any}) => {b: any}; +type UnknownLambda = (value: {a: unknown}) => {b: unknown}; +type NeverLambda = (value: {a: never}) => {b: never}; +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); + +// Lambda: Identical Union and Intersection cases. +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaIntersection = (value: {a: number} & {a: number}) => {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaUnion = (value: {a: number} | {a: number}) => {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaReturnIntersection = (value: {a: number}) => {b: number} & {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaReturnUnion = (value: {a: number}) => {b: number} | {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaBothIntersection = (value: {a: number} & {a: number}) => {b: number} & {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type NumberLambdaBothUnion = (value: {a: number} | {a: number}) => {b: number} | {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Date cases. +const foo = {date: new Date(), a: null} as const; +expectType({} as IsEqual); +expectType({} as IsEqual<{a: null}, typeof foo>); +expectType({} as IsEqual<{date: Date}, typeof foo>); +// Date: Identical Union and Intersection cases. + +// Set cases. +expectType({} as IsEqual, Set>); +expectType({} as IsEqual, Set>); +expectType({} as IsEqual, Set>); + +// Set: Identical Union and Intersection cases. +expectType({} as IsEqual<{a: {b: Set<0> & Set<0>}}, {a: {b: Set<0>}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{a: {b: Set<0> | Set<0>}}, {a: {b: Set<0>}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Set: Identical Union and Intersection cases. +expectType({} as IsEqual<{a: {b: Set<0> & Set<0>}}, {a: {b: Set<0>}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{a: {b: Set<0> | Set<0>}}, {a: {b: Set<0>}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Map cases. +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); +expectType({} as IsEqual, Map>); + +// Map: Identical Union and Intersection cases. +expectType({} as IsEqual | Map, Map>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual & Map, Map>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + type LongTupleNumber = TupleOf<50, 0>; expectType({} as IsEqual); From 5ad2c4d102b35584cb26707e07575959c60b0014 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 13 Feb 2026 04:14:11 +0900 Subject: [PATCH 25/31] update: `UniqueUnionDeep` handles records in tuples. --- source/internal/type.d.ts | 20 +++++++++++++------- test-d/internal/unique-union-deep.ts | 3 +++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 0259ceb90..7b87d62ea 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -4,6 +4,7 @@ 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 {UnionToTuple} from '../union-to-tuple.d.ts'; +import type {SimplifyDeep} from '../simplify-deep.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -189,16 +190,21 @@ type UniqueUnionDeepArgumentsDeep = SimplifyDeep (a: {a: number}) => {b: {b: number}} ``` */ -export type UniqueUnionDeep = +export type UniqueUnionDeep = SimplifyDeep['r']>; + +type RecurseUniqueUnionDeep = U extends Record - ? _UniqueUnionDeep - : U extends Lambda - ? (...args: _UniqueUnionDeep>) => (UniqueUnion<_UniqueUnionDeep>>) - : U; + ? SimplifyUniqueUnionDeep + : U extends UnknownArray + ? SimplifyUniqueUnionDeep + : U extends Lambda + // `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. + ? (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) + : U; /** -Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". +Note: Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". */ -type _UniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; +type SimplifyUniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; type Lambda = ((...args: any[]) => any); diff --git a/test-d/internal/unique-union-deep.ts b/test-d/internal/unique-union-deep.ts index f80f1ab7b..fda015385 100644 --- a/test-d/internal/unique-union-deep.ts +++ b/test-d/internal/unique-union-deep.ts @@ -1,5 +1,6 @@ import {expectType} from 'tsd'; import type {UniqueUnionDeep} from '../../source/internal/type.d.ts'; +import type {IsEqual} from '../../source/is-equal.d.ts'; // The returns of `UniqueUnionDeep` are expected to be equal to `UniqueUnion`, if there's no recursive object type in the passed union type. // That's why those tests are pasted from 'test-d/internal/union-unique.ts'. @@ -31,3 +32,5 @@ expectType<[0, 1, 2] | ['0', unknown] | [0, unknown] | number[]>({} as UniqueUni expectType<{z: {a: {aa: 0}} | {b: 0}; x: '1'}>({} as UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType<{z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'}>({} as UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '2'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +expectType<{a: [{b: 0}]}>({} as UniqueUnionDeep<{a: [{b: 0} | {b: 0}]} | {a: [{b: 0}]}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 9d6aa803f3f67b572757c6d93dd65f4b40aab2f3 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Fri, 13 Feb 2026 04:48:15 +0900 Subject: [PATCH 26/31] test: add deep identical cases for `IsEqual` and `UniqueUnionDeep` --- test-d/internal/unique-union-deep.ts | 4 +++- test-d/is-equal.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/test-d/internal/unique-union-deep.ts b/test-d/internal/unique-union-deep.ts index fda015385..836eb2b61 100644 --- a/test-d/internal/unique-union-deep.ts +++ b/test-d/internal/unique-union-deep.ts @@ -1,6 +1,5 @@ import {expectType} from 'tsd'; import type {UniqueUnionDeep} from '../../source/internal/type.d.ts'; -import type {IsEqual} from '../../source/is-equal.d.ts'; // The returns of `UniqueUnionDeep` are expected to be equal to `UniqueUnion`, if there's no recursive object type in the passed union type. // That's why those tests are pasted from 'test-d/internal/union-unique.ts'. @@ -33,4 +32,7 @@ expectType<[0, 1, 2] | ['0', unknown] | [0, unknown] | number[]>({} as UniqueUni expectType<{z: {a: {aa: 0}} | {b: 0}; x: '1'}>({} as UniqueUnionDeep<{z: {a: {aa: 0} | {aa: 0}} | {a: {aa: 0} | {aa: 0}} | {b: 0}; x: '1'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType<{z: {a: 0}; x: '1'} | {z: {a: 0}; x: '2'}>({} as UniqueUnionDeep<{z: {a: 0} | {a: 0}; x: '1'} | {z: {a: 0} | {a: 0}; x: '2'}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +type UniqueUnionDeepArgumentsDeep = UniqueUnionDeep<(a: {a: number} | {a: number}) => {b: {b: number} | {b: number}}>; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType<(a: {a: number}) => {b: {b: number}}>({} as UniqueUnionDeepArgumentsDeep); + expectType<{a: [{b: 0}]}>({} as UniqueUnionDeep<{a: [{b: 0} | {b: 0}]} | {a: [{b: 0}]}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 11f99c96b..72a58adb7 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -187,3 +187,20 @@ expectType({} as IsEqual<{readonly a: 0} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); expectType({} as IsEqual<{readonly aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {readonly a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Assume that two lambdas `Parameters` and `ReturnType` are equal, `IsEqual` returns `true`. +type ArgumentsExpected = (a: {a: number}) => {b: number}; +type ArgumentsIdenticalUnion = (a: {a: number} | {a: number}) => ({b: number} | {b: number}); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); +type ArgumentsIdenticalIntersection = (a: {a: number} & {a: number}) => ({b: number} & {b: number}); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual {b: number}>); +type ArgumentsDeepExpected = (a: {a: {b: number}}) => {b: {b: number}}; +type ArgumentsIdenticalUnionDeep = (a: {a: {b: number} | {b: number}}) => {b: {b: number} | {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); +type ArgumentsIdenticalIntersectionDeep = (a: {a: {b: number} & {b: number}}) => {b: {b: number} & {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); + +// Deep identical union/intersection in tuples. +type TupleDeepIdenticalExpected = {a: [{b: 0}]}; +expectType({} as IsEqual<{a: [{b: 0} | {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{a: [{b: 0} & {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 64970436fdeb82779e18b6ef533eb4bd9ec76494 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sat, 21 Mar 2026 04:06:18 +0900 Subject: [PATCH 27/31] fix: revert `IsEqual` and the tests. fix `ExcludeExactly` to improve performance (and fix the doc.) --- source/exclude-exactly.d.ts | 116 ++++++++++++++++++++++++++++-------- source/is-equal.d.ts | 2 +- test-d/is-equal.ts | 46 +++++++------- 3 files changed, 116 insertions(+), 48 deletions(-) diff --git a/source/exclude-exactly.d.ts b/source/exclude-exactly.d.ts index 5ab98c4fa..3f7025d9d 100644 --- a/source/exclude-exactly.d.ts +++ b/source/exclude-exactly.d.ts @@ -1,8 +1,75 @@ 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'; +import type {UnionMember} from './union-member.d.ts'; + +/** +Returns `never` if the 1st and 2nd arguments are identical. +Returns the 1st argument if they are not. +(Note: There are limitations regarding union/intersection types. See `MatchOrNever` or `_IsEqual` in the `source/is-equal.d.ts` documentation.) + +@example +``` +type A = MatchOrNever; +//=> string | number + +type B = MatchOrNever; +//=> never + +type C = MatchOrNever; +//=> string | number + +type D = MatchOrNever; +//=> 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} +``` + +Special cases for `unknown` and `never`, which can easily break equality in type-level codebases. + +@example +``` +type E = MatchOrNever; +//=> unknown + +type F = MatchOrNever; +//=> never + +type G = MatchOrNever; +//=> never + +type H = MatchOrNever; +//=> never +``` + +Note that this does not recursively treat identical union/intersection types (e.g., `T | T` or `T & T`) as `T`. +For instance, `{a: 0} | {a: 0}` is considered identical to `{a: 0}`, but nested properties are not normalized. + +@example +``` +type IDUnion = MatchOrNever<{a: {b: 0}} | {a: {b: 0}}, {a: {b: 0}}>; +//=> never + +type RecurivelyIDUnion = MatchOrNever<{a: {b: 0} | {b: 0}}, {a: {b: 0}}>; +//=> {a: {b: 0} | {b: 0}} +``` +*/ +type MatchOrNever = + [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 extends A & G | G ? 1 : 2) extends (() => G extends B & G | G ? 1 : 2) + ? never + : A; /** A stricter version of `Exclude` that excludes types only when they are exactly identical. @@ -32,26 +99,27 @@ type TestExcludeExactly3 = ExcludeExactly<{a: string} | {a: string; b: string}, @category Improved Built-in */ -export type ExcludeExactly = - IfNotAnyOrNever< - Union, - _ExcludeExactly, - // If `Union` is `any`, then if `Delete` is `any`, return `never`, else return `Union`. - If, never, Union>, - // If `Union` is `never`, then if `Delete` is `never`, return `never`, else return `Union`. - If, never, Union> - >; - -type _ExcludeExactly = - IfNotAnyOrNever, 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 type ExcludeExactly = + IsAny extends true + ? IsAny extends true + ? never + : UnionU + : IsNever extends true + ? IsNever extends true + ? never + : UnionU + : InternalExcludeExactly; + +type InternalExcludeExactly = + UnionMember extends infer D + ? true extends IsNever + ? UnionU + : InternalExcludeExactly<_ExcludeExactly, _ExcludeExactly> + : never; + +type _ExcludeExactly = + UnionU extends unknown // Only for union distribution. + ? MatchOrNever + : never; export {}; diff --git a/source/is-equal.d.ts b/source/is-equal.d.ts index c7c4ef3f3..442164aa4 100644 --- a/source/is-equal.d.ts +++ b/source/is-equal.d.ts @@ -28,7 +28,7 @@ type Includes = */ export type IsEqual = [A, B] extends [B, A] - ? _IsEqual + ? _IsEqual, UniqueUnionDeep> : false; /** diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index a7567f622..72a58adb7 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -73,12 +73,12 @@ type NumberLambdaReturnUnion = (value: {a: number}) => {b: number} | {b: number} type NumberLambdaBothIntersection = (value: {a: number} & {a: number}) => {b: number} & {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents type NumberLambdaBothUnion = (value: {a: number} | {a: number}) => {b: number} | {b: number}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual); -// expectType({} as IsEqual); -// expectType({} as IsEqual); -// expectType({} as IsEqual); -// expectType({} as IsEqual); -// expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); +expectType({} as IsEqual); expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents @@ -171,14 +171,14 @@ type Assignability> = any; type TestAssignability = Assignability; // Ensure `{a: t; b: s}` === `{a: t} & {b: s}`, not equal to `{a: u} & {b: v}` if `u` !== `t` or `v` !== `s`. -// expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); -// expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); +expectType({} as IsEqual<{a: 0} & {b: 0}, {a: 0; b: 0}>); +expectType({} as IsEqual<{aa: {a: {x: 0} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); expectType({} as IsEqual<{a: 1} & {b: 0}, {a: 0; b: 0}>); expectType({} as IsEqual<{aa: {a: {x: 1} & {y: 0}} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // Ensure `{a: t} | {a: t}` === `{a: t}` -// expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{aa: {a: {x: 0} & ({y: 0} | {y: 0})} & {b: 0}}, {aa: {a: {x: 0; y: 0}; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{readonly a: 0} & ({b: 0} | {b: 0}), {a: 0; b: 0}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual<{readonly aa: {a: 0} & ({b: 0} | {b: 0})}, {aa: {a: 0; b: 0}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents @@ -191,16 +191,16 @@ expectType({} as IsEqual<{aa: {a: 0} & {b: 0} | {a: 0} & {b: 0}}, {aa: {r // Assume that two lambdas `Parameters` and `ReturnType` are equal, `IsEqual` returns `true`. type ArgumentsExpected = (a: {a: number}) => {b: number}; type ArgumentsIdenticalUnion = (a: {a: number} | {a: number}) => ({b: number} | {b: number}); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual); -// type ArgumentsIdenticalIntersection = (a: {a: number} & {a: number}) => ({b: number} & {b: number}); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual {b: number}>); -// type ArgumentsDeepExpected = (a: {a: {b: number}}) => {b: {b: number}}; -// type ArgumentsIdenticalUnionDeep = (a: {a: {b: number} | {b: number}}) => {b: {b: number} | {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual); -// type ArgumentsIdenticalIntersectionDeep = (a: {a: {b: number} & {b: number}}) => {b: {b: number} & {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual); - -// // Deep identical union/intersection in tuples. -// type TupleDeepIdenticalExpected = {a: [{b: 0}]}; -// expectType({} as IsEqual<{a: [{b: 0} | {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents -// expectType({} as IsEqual<{a: [{b: 0} & {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); +type ArgumentsIdenticalIntersection = (a: {a: number} & {a: number}) => ({b: number} & {b: number}); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual {b: number}>); +type ArgumentsDeepExpected = (a: {a: {b: number}}) => {b: {b: number}}; +type ArgumentsIdenticalUnionDeep = (a: {a: {b: number} | {b: number}}) => {b: {b: number} | {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); +type ArgumentsIdenticalIntersectionDeep = (a: {a: {b: number} & {b: number}}) => {b: {b: number} & {b: number}}; // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual); + +// Deep identical union/intersection in tuples. +type TupleDeepIdenticalExpected = {a: [{b: 0}]}; +expectType({} as IsEqual<{a: [{b: 0} | {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{a: [{b: 0} & {b: 0}]} | {a: [{b: 0}]}, TupleDeepIdenticalExpected>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From 8a7056bfbb96f4dba5ea8e0dc4904de30c9b54e2 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sat, 21 Mar 2026 22:12:31 +0900 Subject: [PATCH 28/31] refactor: rename `SimplifyUniqueUnionDeep` to `InternalUniqueUnionDeep` --- source/internal/type.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 7b87d62ea..0ae1a48a6 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -194,9 +194,9 @@ export type UniqueUnionDeep = SimplifyDeep['r' type RecurseUniqueUnionDeep = U extends Record - ? SimplifyUniqueUnionDeep + ? InternalUniqueUnionDeep : U extends UnknownArray - ? SimplifyUniqueUnionDeep + ? InternalUniqueUnionDeep : U extends Lambda // `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. ? (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) @@ -204,7 +204,7 @@ type RecurseUniqueUnionDeep = /** Note: Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". */ -type SimplifyUniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; +type InternalUniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; type Lambda = ((...args: any[]) => any); From 4f490d2a8d325b96460e681db1bac6af5a1c2a8d Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sun, 22 Mar 2026 16:07:43 +0900 Subject: [PATCH 29/31] fix: Overload cases --- source/internal/type.d.ts | 12 +++++++++--- test-d/is-equal.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 0ae1a48a6..d38cdab6b 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -198,13 +198,19 @@ type RecurseUniqueUnionDeep = : U extends UnknownArray ? InternalUniqueUnionDeep : U extends Lambda - // `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. - ? (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) + ? IsNever extends true + // `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. + ? HasMultipleCallSignatures extends true + ? U + : (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) + : U : U; + /** Note: Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". */ -type InternalUniqueUnionDeep = {[K in keyof U]: UniqueUnion>}; +type InternalUniqueUnionDeep = + {[K in keyof U]: UniqueUnion>}; type Lambda = ((...args: any[]) => any); diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 72a58adb7..651883ae6 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -63,6 +63,17 @@ expectType({} as IsEqual); expectType({} as IsEqual); expectType({} as IsEqual); +// Branded Type +expectType({} as IsEqual<1 & {foo?: 1}, 1>); +expectType({} as IsEqual<1 & {foo?: 1}, 1 & {foo?: 1}>); +expectType({} as IsEqual<{bar: 'a'} & {foo?: 1}, {bar: 'a'}>); +expectType({} as IsEqual<((value: number) => void) & {foo?: 1}, (value: number) => void>); + +// Overload +expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1}, (value: string) => 1>); +expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, (value: string) => 1>); +expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, {(value: string): 1; key: 'value'}>); + // Lambda: Identical Union and Intersection cases. expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents From eb8828bbebe0fca67255f5e9176dc09db0465266 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Sun, 22 Mar 2026 19:42:45 +0900 Subject: [PATCH 30/31] fix: Branded Type with Tuple --- source/internal/type.d.ts | 7 ++++--- test-d/is-equal.ts | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index d38cdab6b..78d4a9482 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -4,7 +4,6 @@ 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 {UnionToTuple} from '../union-to-tuple.d.ts'; -import type {SimplifyDeep} from '../simplify-deep.d.ts'; /** Matches any primitive, `void`, `Date`, or `RegExp` value. @@ -190,7 +189,9 @@ type UniqueUnionDeepArgumentsDeep = SimplifyDeep (a: {a: number}) => {b: {b: number}} ``` */ -export type UniqueUnionDeep = SimplifyDeep['r']>; +export type UniqueUnionDeep = + /** Note: Wrapping this with `SimplifyDeep`, `test-d/is-equal.ts` fails in `Branded Type with Tuple`. */ + RecurseUniqueUnionDeep<{r: U}>['r']; type RecurseUniqueUnionDeep = U extends Record @@ -199,7 +200,7 @@ type RecurseUniqueUnionDeep = ? InternalUniqueUnionDeep : U extends Lambda ? IsNever extends true - // `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. + /** `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. */ ? HasMultipleCallSignatures extends true ? U : (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 651883ae6..7cb8e0607 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -63,9 +63,13 @@ expectType({} as IsEqual); expectType({} as IsEqual); expectType({} as IsEqual); -// Branded Type +// Branded Type with Tuple +expectType({} as IsEqual<[0, 1] & {foo?: 1}, [0, 1]>); +expectType({} as IsEqual<[0, 1] & {foo?: 1}, [0, 1] & {foo?: 1}>); +// Branded Type with Primitive expectType({} as IsEqual<1 & {foo?: 1}, 1>); expectType({} as IsEqual<1 & {foo?: 1}, 1 & {foo?: 1}>); +// Branded Type with Lambda expectType({} as IsEqual<{bar: 'a'} & {foo?: 1}, {bar: 'a'}>); expectType({} as IsEqual<((value: number) => void) & {foo?: 1}, (value: number) => void>); From 0298dad3cfbf01260f50ff705689ec18351d2525 Mon Sep 17 00:00:00 2001 From: taiyakihitotsu Date: Thu, 4 Jun 2026 03:27:06 +0900 Subject: [PATCH 31/31] update: handles overload cases --- source/internal/type.d.ts | 99 +++++++++++++++++++++++++++++++++++---- test-d/is-equal.ts | 23 ++++++++- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/source/internal/type.d.ts b/source/internal/type.d.ts index 78d4a9482..36f9766a8 100644 --- a/source/internal/type.d.ts +++ b/source/internal/type.d.ts @@ -36,6 +36,17 @@ Needed to handle the case of a single call signature with properties. Multiple call signatures cannot currently be supported due to a TypeScript limitation. @see https://github.com/microsoft/TypeScript/issues/29732 + +NOTE: +Deduplicate identical union/intersection types in overloads beforehand with `UniqueOverload` if you require a strict result. + +@example +``` +type A = HasMultipleCallSignatures>; +//=> true +type B = HasMultipleCallSignatures<{(a: number): 0; (a: string): 1; (a: string): 1}>; +//=> false +``` */ export type HasMultipleCallSignatures unknown> = T extends {(...arguments_: infer A): unknown; (...arguments_: infer B): unknown} @@ -193,25 +204,97 @@ export type UniqueUnionDeep = /** Note: Wrapping this with `SimplifyDeep`, `test-d/is-equal.ts` fails in `Branded Type with Tuple`. */ RecurseUniqueUnionDeep<{r: U}>['r']; +/** +Note: Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". +*/ +type InternalUniqueUnionDeep = + {[K in keyof U]: UniqueUnion>}; + +type InternalUniqueOverloadDeep unknown> = + /** `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. */ + HasMultipleCallSignatures> extends true + ? U + : (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>); + type RecurseUniqueUnionDeep = U extends Record ? InternalUniqueUnionDeep : U extends UnknownArray ? InternalUniqueUnionDeep : U extends Lambda + /** If `IsNever` returns `true`, `U` is composed only of overloads. */ ? IsNever extends true - /** `Parametes` and `ReturnType` results are possible to be object or lambda; both should be passed into `UniqueUnionDeep`. */ - ? HasMultipleCallSignatures extends true - ? U - : (...args: UniqueUnionDeep> extends infer A extends any[] ? A : never) => (UniqueUnionDeep>) - : U + ? InternalUniqueOverloadDeep + /** Separates an object into the record and the overload parts. */ + : UniqueUnionDeep> & UniqueUnionDeep> : U; +type UniqueOverload unknown> = + T extends { + (...args_: infer Arguments0): infer Return0; + (...args_: infer Arguments1): infer Return1; + (...args_: infer Arguments2): infer Return2; + (...args_: infer Arguments3): infer Return3; + } + ? DedupeTuple<[(...args_: Arguments0) => Return0, (...args_: Arguments1) => Return1, (...args_: Arguments2) => Return2, (...args_: Arguments3) => Return3]> extends infer TupleOverload extends Lambda[] + ? TupleToOverload + : never + : never; // Unreacheable. + +type TupleToOverload = _TupleToOverload; + +type _TupleToOverload = + T extends [(...args_: infer Arguments0) => infer Return0, (...args_: infer Arguments1) => infer Return1, (...args_: infer Arguments2) => infer Return2, (...args_: infer Arguments3) => infer Return3] + ? {(...args_: Arguments0): Return0; (...args_: Arguments1): Return1; (...args_: Arguments2): Return2; (...args_: Arguments3): Return3} + : T extends [(...args_: infer Arguments0) => infer Return0, (...args_: infer Arguments1) => infer Return1, (...args_: infer Arguments2) => infer Return2] + ? {(...args_: Arguments0): Return0; (...args_: Arguments1): Return1; (...args_: Arguments2): Return2} + : T extends [(...args_: infer Arguments0) => infer Return0, (...args_: infer Arguments1) => infer Return1] + ? {(...args_: Arguments0): Return0; (...args_: Arguments1): Return1} + : T extends [(...args_: infer Arguments0) => infer Return0] + ? (...args_: Arguments0) => Return0 + : never; + /** -Note: Wrapping this with `Simplify`, `test-d/exact.ts` fails in "Spec: recursive type with union". +@example +``` +type DedupeTuple_Test0 = DedupeTuple<[]>; +//=> [] +type DedupeTuple_Test1 = DedupeTuple<[0, 1]>; +//=> [0, 1] +type DedupeTuple_Test2 = DedupeTuple<[0, 1, number]>; +//=> [0, 1, number] +type DedupeTuple_Test3 = DedupeTuple<[number, 0, 1, number]>; +//=> [number, 0, 1] +type DedupeTuple_Test4 = DedupeTuple<[never, string, '9']>; +//=> [never, string, '9'] +type DedupeTuple_Test5 = DedupeTuple<[string, never, string, '9']>; +//=> [string, never, '9'] +type DedupeTuple_Test6 = DedupeTuple<[unknown, string, '9']>; +//=> [unknown, string, '9'] +type DedupeTuple_Test7 = DedupeTuple<[string, unknown, string, '9']>; +//=> [string, unknown, '9'] +``` */ -type InternalUniqueUnionDeep = - {[K in keyof U]: UniqueUnion>}; +export type DedupeTuple = _DedupeTuple; + +type _DedupeTuple = + Tuple['length'] extends 0 + ? Return + : Tuple extends [infer Head, ...infer Tail] + ? IsNever extends true + ? _DedupeTuple + : [Compare extends unknown // Union Distribution. + ? _IsEqual<{_compare: Head}, Compare> + : never] extends [false] + ? _DedupeTuple + : _DedupeTuple + : never; // Unreacheable. + +type _IsEqual = + (() => G extends A & G | G ? 1 : 2) extends + (() => G extends B & G | G ? 1 : 2) + ? true + : false; type Lambda = ((...args: any[]) => any); diff --git a/test-d/is-equal.ts b/test-d/is-equal.ts index 7cb8e0607..475434d38 100644 --- a/test-d/is-equal.ts +++ b/test-d/is-equal.ts @@ -73,11 +73,32 @@ expectType({} as IsEqual<1 & {foo?: 1}, 1 & {foo?: 1}>); expectType({} as IsEqual<{bar: 'a'} & {foo?: 1}, {bar: 'a'}>); expectType({} as IsEqual<((value: number) => void) & {foo?: 1}, (value: number) => void>); -// Overload +// Overloads expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1}, (value: string) => 1>); expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, (value: string) => 1>); expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key: 'value'}, {(value: string): 1; key: 'value'}>); +declare function testf(a: string): number; +declare function testf(a: number): string; + +declare function testg(a: number): string; +declare function testg(a: string): number; +expectType({} as IsEqual); + +expectType({} as IsEqual<{(a: string): {rr: number}; (a: string): {rr: number}}, (a: string) => {rr: number}>); +expectType({} as IsEqual<{(a: {aa: string} | {aa: string}): {rr: number}; (a: {aa: string}): {rr: number}}, (a: {aa: string}) => ({rr: number} | {rr: number})>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{(a: {aa: string} & {aa: string}): {rr: number}; (a: {aa: string}): {rr: number}}, (a: {aa: string}) => ({rr: number} & {rr: number})>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{(a: {aa: string} | {aa: string}): {rr: number}; (a: {aa: string}): {rr: number}}, (a: {aa: string} | {aa: string}) => ({rr: number} | {rr: number})>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{(a: {aa: string} & {aa: string}): {rr: number}; (a: {aa: string}): {rr: number}}, (a: {aa: string} & {aa: string}) => ({rr: number} & {rr: number})>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + +// Object: Overload & Record. +expectType({} as IsEqual<{key: 'value'; (a: string): {rr: number}}, {key: 'value'; (a: string): {rr: number}}>); +expectType({} as IsEqual<{key: 'value'; (a: string): {rr: number}; (a: string): {rr: number}}, {key: 'value'; (a: string): {rr: number}}>); + +expectType({} as IsEqual<{(value: 'a'): 1; (value: string): 1; key0: {key1: {key2: 'value2'} | {key2: 'value2'}}}, {(value: string): 1; key0: {key1: {key2: 'value2'} | {key2: 'value2'}}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{(value: string): 1; key0: {key1: {key2: 'value2'} | {key2: 'value2'}}}, {(value: string): 1; key0: {key1: {key2: 'value2'} | {key2: 'value2'}}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents +expectType({} as IsEqual<{(value: string): 1; key0: {key1: {key2: 'value2'} | {key2: 'value2'}}}, {(value: string): 1; key0: {key1: {key2: 'value2'}}}>); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents + // Lambda: Identical Union and Intersection cases. expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents expectType({} as IsEqual); // eslint-disable-line @typescript-eslint/no-duplicate-type-constituents