Skip to content

UnionToTuple: Fix behavior when a union member is a supertype of another; Add ExcludeExactly type#1349

Merged
sindresorhus merged 23 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/union-to-tuple-20260206
Mar 19, 2026
Merged

UnionToTuple: Fix behavior when a union member is a supertype of another; Add ExcludeExactly type#1349
sindresorhus merged 23 commits intosindresorhus:mainfrom
taiyakihitotsu:fix/union-to-tuple-20260206

Conversation

@taiyakihitotsu
Copy link
Contributor

@taiyakihitotsu taiyakihitotsu commented Feb 5, 2026

The current (7c82a21) UnionToTuple fails in the following case:

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

This is npm test error:

  test-d/union-to-tuple.ts:16:0
  ✖  16:0  Parameter type { readonly a?: 0; } is not identical to argument type DifferentModifiers.

Add ExcludeExactly

This is caused by built-in Exclude, used in UnionToTuple.
This doesn't distinguish between readonly modifiers of objects.

type A = Exclude<{readonly a: 0} | {a: 0} | {readonly a?: 0} | {a?: 0}, {a: 0}>;
//=> {readonly a?: 0} | {a?: 0}
type B = Exclude<{readonly a: 0} | {a: 0}, {a: 0}>;
//=> never

type A = ExcludeStrict<{readonly a: 0} | {a: 0} | {readonly a?: 0} | {a?: 0}, {a: 0}>;
//=> {readonly a?: 0} | {a?: 0}
type B = ExcludeStrict<{readonly a: 0} | {a: 0}, {a: 0}>;
//=> never

This is a spec: https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers

Constructs a type by excluding from UnionType all union members that are assignable to ExcludedMembers.

And I think it's not a bug of type-fest's ExcludeStrict, because it also refers to assignability here: https://github.com/sindresorhus/type-fest/blob/main/source/extends-strict.d.ts#L5

A stricter, non-distributive version of extends for checking whether one type is assignable to another.

Both are based on assignability, but we need stricter version of Exclude and this PR defines it as ExcludeExactly.

Add LastOfUnion

And to define it, we need another local type LastOfUnion, using a well-known definition, defined already in IsNever.
This is useful to recurse union-types, so I think this should be exported as a global.
(And we can evade a duplicated definition potentially).

And the current LastOfUnion used in IsNever, this test case doesn't pass:

expectType<never>({} as LastOfUnion<never>);

because LastOfUnion<never> returns unknown.
Ideally, LastOfUnion reduces a type, e.g., $A \cup B$ -> $B$, so $\emptyset$ -> $\emptyset$ intuitively ($\bot$ -> $\bot$).
This PR's LastOfUnion handles this.

Fix UnionToTuple

Here is the definition:

export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
	? ExcludeExactly<T, L> extends infer E // Improve performance.
		? [...UnionToTuple<E>, L]
		: never // Unreachable.
	: [];

There're 2 diff.


This PR includes the above 3 changes.

Refers to: https://github.com/sindresorhus/type-fest/blob/main/.github/contributing.md

One type addition per pull request, unless they are connected.

I think this is a "connected" case, because

  • UnionToTuple needs ExcludeExactly
  • ExcludeExactly needs LastOfUnion

(This PR can be split into three parts, though it would require careful consideration of the merge order: LastOfUnion -> ExcludeExactly -> UnionToTuple)


NOTE:
This is separate from #1338.

@som-sm
Copy link
Collaborator

som-sm commented Feb 6, 2026

This doesn't distinguish between readonly modifiers of objects.

BTW, this is not just about property modifiers on objects. This would happen anytime TupleToUnion is instantiated with a union that has a member that is a supertype of another member, and LastOfUnion picks up the supertype member before the subtype member. Refer to these examples:

type Union1 = {a: string; b: string} | {a: string};
type Tuple1 = UnionToTuple<Union1>;
//=> [{a: string}]

type Union2 = {a: 1} | {b: 1} | {[x: string]: number};
type Tuple2 = UnionToTuple<Union2>;
//=> [{[x: string]: number}]

Note: In both the above examples, for the error to be noticeable, it's important that LastOfUnion picks up the supertype before the subtype. Otherwise, the error wouldn't surface. Refer to the following:

import type {UnionToTuple} from 'type-fest';

type Union1 = {a: string} | {a: string; b: string};
type Tuple1 = UnionToTuple<Union1>;
//=> [{a: string}, {a: string; b: string}]

type Union2 = {[x: string]: number} | {a: 1} | {b: 1};
type Tuple2 = UnionToTuple<Union2>;
//=> [{[x: string]: number}, {a: 1}, {b: 1}]

@som-sm
Copy link
Collaborator

som-sm commented Feb 6, 2026

BTW, this is not just about property modifiers on objects. This would happen anytime TupleToUnion is instantiated with a union that has a member that is a supertype of another member, and LastOfUnion picks up the supertype member before the subtype member. Refer to these examples:

In order to make the test reliable, we need a union that contains two members A and B such that both A extends B and B extends A hold true. One such union is:

type Union = {a: string} | {readonly a: string}

In the above union, both the object types are supertypes of each other because the readonly modifier currently doesn't affect assignability:

type T1 = {a: string} extends {readonly a: string} ? true : false;
//=> true
type T2 = {readonly a: string} extends {a: string} ? true : false;
//=> true

Hence, the above union is a test case that is always guaranteed to fail:

type Union1 = {readonly a: string} | {a: string};
type Tuple1 = UnionToTuple<Union1>;
//=> [{a: string}]

type Union2 = {a: string} | {readonly a: string};
type Tuple2 = UnionToTuple<Union2>;
//=> [{readonly a: string}]

@taiyakihitotsu
Copy link
Contributor Author

@sindresorhus

Thank you for fixing and refining the documentation!


@som-sm

BTW, this is not just about property modifiers on objects.

Thanks for your information!
I've added test cases to pass it.

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

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

In the above union, both the object types are supertypes of each other because the readonly modifier currently doesn't affect assignability:

type T1 = {a: string} extends {readonly a: string} ? true : false;
//=> true
type T2 = {readonly a: string} extends {a: string} ? true : false;
//=> true

I believe this test case aims to prove that the order of the union does not affect the result of UnionToTuple (or to be more precise, the result of UnionToTuple<U>[number]), correct?
Please let me know if I have overlooked something.
If I’ve misunderstood your point, please feel free to ignore the rest of this comment.

If my understanding is correct, we still cannot predict the order of union member picking as we might hope.
We should probably assume the order is completely arbitrary, as TypeScript does not guarantee it by any means, unfortunately.

I’ve dug into checker.ts in the TypeScript source (v5.9.3, https://raw.githubusercontent.com/microsoft/TypeScript/refs/tags/v5.9.3/src/compiler/checker.ts) to observe how this behaves.
It seems we cannot control the order because it relies on internal ids and binarySearch.
Since we cannot expect to control these internal factors, relying on a specific order might be risky.

Taking this into account, I believe your proposed test case can be fully covered by the following cases.

https://github.com/sindresorhus/type-fest/pull/1349/changes#diff-6ce133a34fd79b9e6b57c337b69241c8a393399e40f7e37e64bba9e421d7c2b0R5

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

What are your thoughts on this?

checker.ts code

    function createType(flags: TypeFlags): Type {
        const result = new Type(checker, flags);
        typeCount++;
        result.id = typeCount;
        tracing?.recordType(result);
        return result;
    }
    function addTypeToUnion(typeSet: Type[], includes: TypeFlags, type: Type) {
        const flags = type.flags;
        // We ignore 'never' types in unions
        if (!(flags & TypeFlags.Never)) {
            includes |= flags & TypeFlags.IncludesMask;
            if (flags & TypeFlags.Instantiable) includes |= TypeFlags.IncludesInstantiable;
            if (flags & TypeFlags.Intersection && getObjectFlags(type) & ObjectFlags.IsConstrainedTypeVariable) includes |= TypeFlags.IncludesConstrainedTypeVariable;
            if (type === wildcardType) includes |= TypeFlags.IncludesWildcard;
            if (isErrorType(type)) includes |= TypeFlags.IncludesError;
            if (!strictNullChecks && flags & TypeFlags.Nullable) {
                if (!(getObjectFlags(type) & ObjectFlags.ContainsWideningType)) includes |= TypeFlags.IncludesNonWideningType;
            }
            else {
                const len = typeSet.length;
                const index = len && type.id > typeSet[len - 1].id ? ~len : binarySearch(typeSet, type, getTypeId, compareValues);
                if (index < 0) {
                    typeSet.splice(~index, 0, type);
                }
            }
        }
        return includes;
    }

@som-sm
Copy link
Collaborator

som-sm commented Feb 6, 2026

@taiyakihitotsu

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

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

These test cases are not reliable because they may pass even with the existing incorrect implementation.

For example, in the first example, if the first type picked up from the union is {a: string; b: string}, then Exclude<UnionSuperType0, {a: string; b: string}> would correctly return {a: string}, and there would be "no" error.

The error only happens if the first type picked up is {a: string} because then Exclude<UnionSuperType0, {a: string}> would return never instead of {a: string; b: string}.


If my understanding is correct, we still cannot predict the order of union member picking as we might hope. We should probably assume the order is completely arbitrary, as TypeScript does not guarantee it by any means, unfortunately.

You didn’t get me. The order is not predictable. That’s clear, that’s the whole point.

What I meant is that we need a test case that "always" fails with the existing implementation, regardless of the order in which things happen. Therefore, I suggested a test case that's "guaranteed" to fail with the existing implementation.

type Union = {readonly a: string} | {a: string};
expectType<Union>({} as UnionToTuple<Union>[number]);

In this case, with the existing implementation, the output of UnionToTuple will only ever have a single element. We "cannot" tell which element it will be, but it will always be just one. Thus, this case always fails.


Taking this into account, I believe your proposed test case can be fully covered by the following cases.

https://github.com/sindresorhus/type-fest/pull/1349/changes#diff-6ce133a34fd79b9e6b57c337b69241c8a393399e40f7e37e64bba9e421d7c2b0R5

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

The proposed test case is "already" fully covered. In the above test case the only necessary members are {a: 0} and {readonly a: 0}, all the other members are unnecessary.


Again, this problem has nothing to do with object property modifiers. The issue can occur even without property modifiers, as illustrated in the earlier examples. But, to ensure our test case is not flaky, we need a case where A extends B and B extends A, and it just so happens that {readonly property: type} extends {property: type} and vice versa. Hope it's clear now!

@som-sm
Copy link
Collaborator

som-sm commented Feb 6, 2026

This doesn't distinguish between readonly modifiers of objects.

BTW, this is not just about property modifiers on objects. This would happen anytime TupleToUnion is instantiated with a union that has a member that is a supertype of another member, and LastOfUnion picks up the supertype member before the subtype member. Refer to these examples:

type Union1 = {a: string; b: string} | {a: string};
type Tuple1 = UnionToTuple<Union1>;
//=> [{a: string}]

type Union2 = {a: 1} | {b: 1} | {[x: string]: number};
type Tuple2 = UnionToTuple<Union2>;
//=> [{[x: string]: number}]

Note: In both the above examples, for the error to be noticeable, it's important that LastOfUnion picks up the supertype before the subtype. Otherwise, the error wouldn't surface. Refer to the following:

import type {UnionToTuple} from 'type-fest';

type Union1 = {a: string} | {a: string; b: string};
type Tuple1 = UnionToTuple<Union1>;
//=> [{a: string}, {a: string; b: string}]

type Union2 = {[x: string]: number} | {a: 1} | {b: 1};
type Tuple2 = UnionToTuple<Union2>;
//=> [{[x: string]: number}, {a: 1}, {b: 1}]

Just to clarify, here I was only trying to emphasize that this problem is not limited to object property modifiers. I did "not" mean that this should be used as a test case. It should not be, since the behavior here is not predictable.

expectType<never>({} as ExcludeExactly<unknown, unknown>);
expectType<never>({} as ExcludeExactly<unknown, any>);
expectType<never>({} as ExcludeExactly<any, any>);
expectType<never>({} as ExcludeExactly<any, unknown>);
Copy link
Collaborator

Choose a reason for hiding this comment

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

// unknown and any exclude themselves.

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

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

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

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 6, 2026

Choose a reason for hiding this comment

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

// unknown and any exclude themselves.

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

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

#1349 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

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

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

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

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


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


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

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 7, 2026

Choose a reason for hiding this comment

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

Sorry, completely my bad...

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

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

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

Comment on lines +31 to +33
// `unknown` and `any` exclude other types.
expectType<never>({} as ExcludeExactly<string | number, unknown>);
expectType<never>({} as ExcludeExactly<string | number, any>);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similar question here.

Should be this IMO:

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

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

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

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@taiyakihitotsu
Copy link
Contributor Author

@som-sm

Thanks for your clarification and review!

What you wanted to say was that we shouldn't focus on the unpredictable union order itself, but on the test cases that must fail with the old UnionToTuple but must pass with the new UnionToTuple, right?

The case below makes your point clear to me.

type OldUnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
	? Exclude<T, L> extends infer E // Improve performance.
		? [...UnionToTuple<E>, L]
		: never // Unreachable.
	: [];

type Union1 = {a: string; b: string} | {a: string}
type Tuple1 = OldUnionToTuple<Union1>;
//=> [{a: string}]

type Union2 = {a: string} | {a: string; b: string}
type Tuple2 = OldUnionToTuple<Union2>;
//=> [{a: string}, {a: string, b: string}]

(But, extremely, I think, we shouldn't expect that writing A | B means "writing A | B"; it's possible for it to be interpreted as "writing B | A". I don't know how to handle it, and I think it cannot be done theoretically...)


Now I've addressed your feedback by adding reversed test cases in test-d/union-to-tuple.ts.

And add 2 cases more:

  • Identical union case: because A | B is not done by eager evaluation directly to A even if we know A is equal to B. So it leaves a possibility that we can doubt if UnionToTuple seems to return a 2-length tuple in this case.
  • length test: Important especially for identical union case.

Do these new test cases cover your concerns?
I hope so.

@taiyakihitotsu taiyakihitotsu requested a review from som-sm February 6, 2026 20:25
@som-sm
Copy link
Collaborator

som-sm commented Feb 7, 2026

(But, extremely, I think, we shouldn't expect that writing A | B means "writing A | B"; it's possible for it to be interpreted as "writing B | A". I don't know how to handle it, and I think it cannot be done theoretically...)

Again, there's some confusion, I "never" meant we should treat A | B as A | B. Let me clarify again:

  1. When I say "existing implementation", I mean the implementation of UnionToTuple that is there on main, i.e., this one:

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

    If that was the source of the confusion, I’d suggest revisiting the comments with this clarified.

  2. We need a test case surely fails with the "existing implementation" of UnionToTuple. So, we can't use the following test case because it is not guaranteed to fail.

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

    So, in future, someone might remove the fix applied in this PR, and the tests might still "pass", which is something we want to prevent.

taiyakihitotsu and others added 2 commits February 7, 2026 19:43
Co-authored-by: Som Shekhar Mukherjee <49264891+som-sm@users.noreply.github.com>
@taiyakihitotsu
Copy link
Contributor Author

@som-sm

Do these tests fill the requirements?

https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbwKYA8xIMYwCoE90C+cAZlBCHAOQwDOAJpQNwBQokscM+SiAqgHbAI-bBACS-GEig1MMIfwA0cMTQBySAG7TlqgKIBHAK4BDADZFS5Kl3QBaYkhowmzZqnbxbPADInnAPLEAgoAPNgAfHAAvMwAkDBQRjyoUvx0NCrqWtLhEfFxAPxw-DlQBQBccCHCohJSMnJh2HCpSOmZJvy4cMUAFACUMVEtVaXaUFFtHXCDw7PA-I5QcABKAwVFa5tjZSzuaNBe3NWCtRDYRmBmSOHKPjFwfoHBZ-x5UbGqGhN5rShpDIkcyyeLFPQoDBmIx0W7Ye5TAHtIGLZZwPRwAD0mJU4DI2jg6CgxGgIC6GCQADoCsUANqUhk1EQXK43UJ6CL3AC6lRKZSxOIEUCQJgwAAsTAAjG7UuJVWlc-beOAAEWAxGW7RgAFkIHR1cBpJloogTFUAAxEAA+iGFJjowjMPTNcEtLA8cjw6FCTNEl2utzVGukWt1+uIhpkEVp-CMIEl0i5ET6CCI-lV6s1kjDBqNA3daE93B9bz9rMDmZD2b1uajtMoN34AHMYGLKEm+gAmfNuZWrMqyOhBrM6msRo2PBB2h38J1wF2WuA2hALggF9BYL23X0sgOhfsTQfDquj8ORmjR2PxxPJ1PzzIHo1IIeV4XVs959dF707-1sx+NC+wZvqetYXvWjYtm2HbdiwQA

type DifferentModifiers = {a: 0} | {readonly a: 0};
expectType<UnionToTuple<DifferentModifiers>[number]>({} as DifferentModifiers);
expectType<UnionToTuple<DifferentModifiers>['length']>(2);

expectType<UnionToTuple<DifferentModifiers>[number]>({} as DifferentModifiers); ensures the identity of elements.
expectType<UnionToTuple<DifferentModifiers>['length']>(2); ensures the identity of the count of the union type and the length of result.

These tests were added and passed by this PR's UnionToTuple:

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

type ReversedDifferentModifiers = {readonly a: 0} | {a: 0};
expectType<UnionToTuple<ReversedDifferentModifiers>[number]>({} as ReversedDifferentModifiers);
expectType<UnionToTuple<ReversedDifferentModifiers>['length']>(2);

@som-sm som-sm linked an issue Feb 7, 2026 that may be closed by this pull request
@taiyakihitotsu taiyakihitotsu force-pushed the fix/union-to-tuple-20260206 branch from 98b5a03 to 01cbb49 Compare February 7, 2026 22:31
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Feb 7, 2026

@som-sm

Regarding your point about the deficiency in the UnionToTuple test cases, I realized the same issue could apply to LastOfUnion, so I’ve added tests for it as well, 01cbb49.

The structure of these tests is identical to the length check I added for UnionToTuple, but the specific goal here is to verify whether LastOfUnion can truly handle all members of a union.
For instance,LastOfUnion<{readonly a: 0} | {a: 0}> has a risk that only one of them is returned.
The same concern applies to supertype cases like LastOfUnion<{a: 0, b: 0} | {a: 0}>.

In these tests, I needed a UnionToTuple definition that uses the LastOfUnion.
Therefore, I have redefined my first definition as a local type within test-d/last-of-union.ts.

If we go with my definition (of course it should be fixed about any and unknown cases), this entire local redefinition can be removed.
On the other hand, if we use your definition, it would be possible to split the changes to last-of-union.d.ts into a separate PR.

What are your thoughts on this?


After I've re-read your comment, I've realized this is what you pointed out now.

#1349 (comment)

The proposed test case is "already" fully covered. In the above test case the only necessary members are {a: 0} and {readonly a: 0}, all the other members are unnecessary.

Comment on lines +30 to +44
type UnionSuperType0 = {a: string; b: string} | {a: string};
expectType<UnionSuperType0>({} as UnionToTuple<UnionSuperType0>[number]);
expectType<UnionToTuple<UnionSuperType0>['length']>(2);

type ReversedUnionSuperType0 = {a: string} | {a: string; b: string};
expectType<ReversedUnionSuperType0>({} as UnionToTuple<ReversedUnionSuperType0>[number]);
expectType<UnionToTuple<ReversedUnionSuperType0>['length']>(2);

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

type ReversedUnionSuperType1 = {[x: string]: number} | {a: 1};
expectType<ReversedUnionSuperType1>({} as UnionToTuple<ReversedUnionSuperType1>[number]);
expectType<UnionToTuple<ReversedUnionSuperType1>['length']>(2);
Copy link
Collaborator

Choose a reason for hiding this comment

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

As mentioned previously, these cases are flaky, and all of them "can" pass with the existing incorrect implementation of UnionToTuple on main. Please remove them.

Copy link
Collaborator

@som-sm som-sm Feb 11, 2026

Choose a reason for hiding this comment

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

I am not sure where the confusion is, I thought we were clear on this.

Can you tell me why are these cases required when we already have this:

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

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 11, 2026

Choose a reason for hiding this comment

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

Replaced with super type cases, in exclude-exactly, union-to-tuple, last-of-union.
(And add comment about the union test's limitation.)

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 11, 2026

Choose a reason for hiding this comment

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

// TypeScript doesn't distinguish the order of a union type, but this is the only way we can write the tests.
type SuperTypeCase = {a: 0} | {a: 0; b: 0};
expectType<UnionToTuple<SuperTypeCase>[number]>({} as SuperTypeCase);
expectType<UnionToTuple<SuperTypeCase>['length']>(2);

type SuperTypeCaseReversed = {a: 0; b: 0} | {a: 0};
expectType<UnionToTuple<SuperTypeCaseReversed>[number]>({} as SuperTypeCaseReversed);
expectType<UnionToTuple<SuperTypeCaseReversed>['length']>(2);

If Exclude happens to produce the same result as ExcludeExactly, it's probably impossible to write a test case that only ExcludeExactly would pass.
Given that, I think it's better to keep this case rather than remove it.

What do you think?

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 11, 2026

Choose a reason for hiding this comment

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

Can you tell me why are these cases required when we already have this:

Sorry, I misread your comment. Please ignore my previous replies.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry, I misread your comment. Please ignore my previous replies.

So, we're clear on this, right? That we don't need all these cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So, we're clear on this, right? That we don't need all these cases.

Currently, I've left DifferentModifierUnion test and removed UnionSuperType0 ~ ReversedUnionSuperType1 in test-d/union-to-tuple.ts.

If that's what you were referring to, then yes.

@taiyakihitotsu taiyakihitotsu force-pushed the fix/union-to-tuple-20260206 branch from a9015cc to e4bd1e8 Compare February 11, 2026 20:51
@taiyakihitotsu taiyakihitotsu force-pushed the fix/union-to-tuple-20260206 branch 3 times, most recently from 5fe4d9e to e4bd1e8 Compare February 12, 2026 17:28
@taiyakihitotsu
Copy link
Contributor Author

taiyakihitotsu commented Feb 12, 2026

@som-sm

I've removed any changes about the identical union case tests and UniqueUnionDeep.
How does it look now?

Copy link
Collaborator

@som-sm som-sm left a comment

Choose a reason for hiding this comment

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

Added comments.

I've also made some improvements in ExcludeExactly tests, please review them once.

export type UnionToTuple<T, L = LastOfUnion<T>> =
IsNever<T> extends false
? [...UnionToTuple<Exclude<T, L>>, L]
? ExcludeExactly<T, L> extends infer E // Improve performance.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can there be a test that validates this perf improvement?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can there be a test that validates this perf improvement?

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


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

I made a branch to reproduce this.

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


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

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

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

@taiyakihitotsu
Copy link
Contributor Author

@som-sm

I've also made some improvements in ExcludeExactly tests, please review them once.

Thank you!
I definitely missed the unknown and any scenarios.

Copy link
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 14, 2026

Choose a reason for hiding this comment

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

@som-sm

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is the expected workflow:

How does this look?

Copy link
Collaborator

Choose a reason for hiding this comment

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

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

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

Copy link
Contributor Author

@taiyakihitotsu taiyakihitotsu Feb 14, 2026

Choose a reason for hiding this comment

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

Sorry for the confusion, I meant this part here: https://github.com/sindresorhus/type-fest/pull/1368/changes#diff-6ce133a34fd79b9e6b57c337b69241c8a393399e40f7e37e64bba9e421d7c2b0R31

I've edited the comment to clear it.

@taiyakihitotsu taiyakihitotsu changed the title fix: UnionToTuple, add: LastOfUnion, ExcludeExactly. fix: UnionToTuple, add: ExcludeExactly. Feb 14, 2026
Comment on lines +6 to +45
Return a member of a union type. Order is not guaranteed.
Returns `never` when the input is `never`.

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

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

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

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

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

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

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

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

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

Choose a reason for hiding this comment

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

Why are these changes are still here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll remove this part after #1368 is merged.
Setting this to draft just in case.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'll remove this part after #1368 is merged.
Setting this to draft just in case.

But why? This isn't dependent on #1368, right? This is already reviewed, so it can be merged.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Once #1368 is merged, union-to-tuple.d.ts here can import LastOfUnion (which might be renamed to UnionMember) instead of defining it locally.
This will allow us to finally remove this redundant code.

https://github.com/taiyakihitotsu/type-fest/blob/95798257945e8226e967b270722c92d78103671b/source/union-to-tuple.d.ts#L82

export type UnionToTuple<T, L = LastOfUnion<T>> =

Copy link
Collaborator

Choose a reason for hiding this comment

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

@taiyakihitotsu But that's not necessary, let's remove these changes and finalise this first.

Copy link
Collaborator

@som-sm som-sm Feb 15, 2026

Choose a reason for hiding this comment

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

@taiyakihitotsu But that's not necessary, let's remove these changes and finalise this first.

I have removed it in 2d728ee, let's see if it breaks anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see what you mean now after looking at your commit 2d728ee.

Thank you!

Copy link
Collaborator

@som-sm som-sm Feb 15, 2026

Choose a reason for hiding this comment

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

Everything seems to be working fine. Let's remove this PR from draft?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Reopened.

@taiyakihitotsu taiyakihitotsu marked this pull request as draft February 15, 2026 13:16
@taiyakihitotsu taiyakihitotsu marked this pull request as ready for review February 15, 2026 13:58
@som-sm som-sm changed the title fix: UnionToTuple, add: ExcludeExactly. UnionToTuple: Fix behavior when a union member is a supertype of another; Add ExcludeExactly type Feb 15, 2026
@sindresorhus sindresorhus merged commit 0f923d0 into sindresorhus:main Mar 19, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UnionToTuple incorrect behavior

3 participants