From 2b963309e902eeb01e88a959ccfac4d536915051 Mon Sep 17 00:00:00 2001 From: YLfjuk Date: Sun, 25 Oct 2026 17:43:48 +0200 Subject: [PATCH 1/2] feat(types): add MemberOfUnion with deprecated LastOfUnion alias --- .changeset/quiet-union-member.md | 6 ++++ packages/core/tests/member-of-union.spec.ts | 18 ++++++++++ .../types/__tests__/last-of-union.spec.ts | 16 ++++++--- .../types/__tests__/member-of-union.spec.ts | 33 +++++++++++++++++++ .../types/__tests__/union-to-tuple.spec.ts | 16 ++++++--- packages/types/src/index.ts | 1 + packages/types/src/last-of-union.ts | 10 +++--- packages/types/src/member-of-union.ts | 14 ++++++++ packages/types/src/union-to-tuple.ts | 15 +++++---- 9 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 .changeset/quiet-union-member.md create mode 100644 packages/core/tests/member-of-union.spec.ts create mode 100644 packages/types/__tests__/member-of-union.spec.ts create mode 100644 packages/types/src/member-of-union.ts diff --git a/.changeset/quiet-union-member.md b/.changeset/quiet-union-member.md new file mode 100644 index 0000000..622bf05 --- /dev/null +++ b/.changeset/quiet-union-member.md @@ -0,0 +1,6 @@ +--- +"@ylfjuk-core/types": patch +"@ylfjuk/core": patch +--- + +Add `MemberOfUnion` as the preferred name for selecting a single union member with unspecified, compiler-dependent selection. Deprecate `LastOfUnion` while retaining it as a compatible alias with unchanged behavior. diff --git a/packages/core/tests/member-of-union.spec.ts b/packages/core/tests/member-of-union.spec.ts new file mode 100644 index 0000000..eb22bba --- /dev/null +++ b/packages/core/tests/member-of-union.spec.ts @@ -0,0 +1,18 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { MemberOfUnion as TypesMemberOfUnion } from "@ylfjuk-core/types"; +import type { LastOfUnion, MemberOfUnion, UnionToTuple } from "@ylfjuk/core"; + +describe("union type exports", () => { + it("exposes the preferred name and compatibility alias through core", () => { + type Input = 1 | 2 | 3 | "bob"; + + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<"bob">(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toBeNever(); + expectTypeOf[number]>().toEqualTypeOf(); + expectTypeOf["length"]>().toEqualTypeOf<4>(); + }); +}); diff --git a/packages/types/__tests__/last-of-union.spec.ts b/packages/types/__tests__/last-of-union.spec.ts index aa38517..3e963a4 100644 --- a/packages/types/__tests__/last-of-union.spec.ts +++ b/packages/types/__tests__/last-of-union.spec.ts @@ -1,12 +1,18 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { LastOfUnion } from "../src/last-of-union"; +import type { LastOfUnion, MemberOfUnion } from "@ylfjuk-core/types"; + +import type { LastOfUnion as LegacyPath } from "../src/last-of-union.js"; describe("LastOfUnion", () => { - it("should extract the last element in a union", () => { - type Actual = LastOfUnion<1 | 2 | 3 | 4 | 5 | "bob" | 6>; - type Expected = 6; + it("retains the public alias and existing source import path", () => { + type Input = 1 | 2 | 3 | "bob"; - expectTypeOf().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf<"bob">(); + expectTypeOf>().toBeNever(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf(); }); }); diff --git a/packages/types/__tests__/member-of-union.spec.ts b/packages/types/__tests__/member-of-union.spec.ts new file mode 100644 index 0000000..c19f6b4 --- /dev/null +++ b/packages/types/__tests__/member-of-union.spec.ts @@ -0,0 +1,33 @@ +import { describe, expectTypeOf, it } from "vitest"; + +import type { MemberOfUnion } from "@ylfjuk-core/types"; + +describe("MemberOfUnion", () => { + it("selects exactly one member without assuming union order", () => { + type Input = 1 | 2 | 3 | "bob"; + type Actual = MemberOfUnion; + + expectTypeOf().not.toBeNever(); + expectTypeOf().toExtend(); + expectTypeOf<[Actual]>().toExtend<[1] | [2] | [3] | ["bob"]>(); + }); + + it("preserves a singleton", () => { + expectTypeOf>().toEqualTypeOf<"bob">(); + }); + + it("returns never for an empty union", () => { + expectTypeOf>().toBeNever(); + }); + + it("selects one boolean literal", () => { + type Actual = MemberOfUnion; + + expectTypeOf().not.toBeNever(); + expectTypeOf<[Actual]>().toExtend<[true] | [false]>(); + }); + + it("respects a union collapsed by the compiler", () => { + expectTypeOf>().toEqualTypeOf(); + }); +}); diff --git a/packages/types/__tests__/union-to-tuple.spec.ts b/packages/types/__tests__/union-to-tuple.spec.ts index 0ab50f4..721a55b 100644 --- a/packages/types/__tests__/union-to-tuple.spec.ts +++ b/packages/types/__tests__/union-to-tuple.spec.ts @@ -1,12 +1,18 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { UnionToTuple } from "../src/union-to-tuple"; +import type { UnionToTuple } from "../src/union-to-tuple.js"; describe("UnionToTuple", () => { - it("should convert a union into an ordered tuple", () => { - type Actual = UnionToTuple<1 | 2 | 3 | 4 | 5 | "bob" | 6>; - type Expected = [1, 2, 3, 4, 5, "bob", 6]; + it("preserves every member and the tuple length without assuming order", () => { + type Input = 1 | 2 | 3 | 4 | 5 | "bob" | 6; + type Actual = UnionToTuple; - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<7>(); + }); + + it("preserves singleton and empty unions", () => { + expectTypeOf>().toEqualTypeOf<["bob"]>(); + expectTypeOf>().toEqualTypeOf<[]>(); }); }); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 25f6add..53c37e0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -29,6 +29,7 @@ export type * from "./mapped-enum.js"; export type * from "./mask-dates.js"; export type * from "./mask-literals.js"; export type * from "./maybe.js"; +export type * from "./member-of-union.js"; export type * from "./non-empty-array.js"; export type * from "./non-optional.js"; export type * from "./omit-by.js"; diff --git a/packages/types/src/last-of-union.ts b/packages/types/src/last-of-union.ts index 3435fc4..0b9eee4 100644 --- a/packages/types/src/last-of-union.ts +++ b/packages/types/src/last-of-union.ts @@ -1,11 +1,11 @@ -import type { UnionToIntersection } from "./union-to-intersection.js"; +import type { MemberOfUnion } from "./member-of-union.js"; /** - * Extracts the last type from a union. + * Selects a single member of a union, or `never` for `never`. * - * @see {@link UnionToIntersection} + * @deprecated Use {@link MemberOfUnion}. Selection is unspecified and + * compiler-dependent; unions have no meaningful order or "last" member. * * @since 0.0.18 */ -export type LastOfUnion = - UnionToIntersection U : never> extends () => infer R ? R : never; +export type LastOfUnion = MemberOfUnion; diff --git a/packages/types/src/member-of-union.ts b/packages/types/src/member-of-union.ts new file mode 100644 index 0000000..2990bdb --- /dev/null +++ b/packages/types/src/member-of-union.ts @@ -0,0 +1,14 @@ +import type { UnionToIntersection } from "./union-to-intersection.js"; + +/** + * Selects a single member of a union, or `never` for `never`. + * + * Selection is unspecified and compiler-dependent. Unions have no meaningful + * order, so the selected member is not a meaningful "last" member. + * + * @see {@link UnionToIntersection} + * + * @since @next + */ +export type MemberOfUnion = + UnionToIntersection U : never> extends () => infer R ? R : never; diff --git a/packages/types/src/union-to-tuple.ts b/packages/types/src/union-to-tuple.ts index 79b0df0..4abde9c 100644 --- a/packages/types/src/union-to-tuple.ts +++ b/packages/types/src/union-to-tuple.ts @@ -1,18 +1,19 @@ -import type { LastOfUnion } from "./last-of-union.js"; +import type { MemberOfUnion } from "./member-of-union.js"; /** - * Converts a union into an ordered tuple. + * Converts a union into a tuple with unspecified, compiler-dependent order. * - * @see {@link LastOfUnion} + * @see {@link MemberOfUnion} * * @example - * type Enum = "bert" | "berta" | "bob"; + * type Name = "bert" | "berta" | "bob"; * - * type EnumAsTuple = UnionToTuple; // ["bert", "berta", "bob"] - * type IsEnumAsTuple = IsTuple; // true + * type NameTuple = UnionToTuple; + * type Members = NameTuple[number]; // "bert" | "berta" | "bob" + * type Length = NameTuple["length"]; // 3 * * @since 0.0.18 */ export type UnionToTuple = [T] extends [never] ? [] - : [...UnionToTuple>>, LastOfUnion]; + : [...UnionToTuple>>, MemberOfUnion]; From 11876c29af8f45216b66d2e7c210491a32d20963 Mon Sep 17 00:00:00 2001 From: YLfjuk Date: Sun, 25 Oct 2026 21:15:10 +0200 Subject: [PATCH 2/2] test(types): enforce single-member selection --- packages/types/__tests__/member-of-union.spec.ts | 6 +++--- packages/types/src/member-of-union.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/types/__tests__/member-of-union.spec.ts b/packages/types/__tests__/member-of-union.spec.ts index c19f6b4..face962 100644 --- a/packages/types/__tests__/member-of-union.spec.ts +++ b/packages/types/__tests__/member-of-union.spec.ts @@ -1,6 +1,6 @@ import { describe, expectTypeOf, it } from "vitest"; -import type { MemberOfUnion } from "@ylfjuk-core/types"; +import type { IsDisjointUnion, MemberOfUnion } from "@ylfjuk-core/types"; describe("MemberOfUnion", () => { it("selects exactly one member without assuming union order", () => { @@ -9,7 +9,7 @@ describe("MemberOfUnion", () => { expectTypeOf().not.toBeNever(); expectTypeOf().toExtend(); - expectTypeOf<[Actual]>().toExtend<[1] | [2] | [3] | ["bob"]>(); + expectTypeOf>().toEqualTypeOf(); }); it("preserves a singleton", () => { @@ -24,7 +24,7 @@ describe("MemberOfUnion", () => { type Actual = MemberOfUnion; expectTypeOf().not.toBeNever(); - expectTypeOf<[Actual]>().toExtend<[true] | [false]>(); + expectTypeOf>().toEqualTypeOf(); }); it("respects a union collapsed by the compiler", () => { diff --git a/packages/types/src/member-of-union.ts b/packages/types/src/member-of-union.ts index 2990bdb..f8dfed1 100644 --- a/packages/types/src/member-of-union.ts +++ b/packages/types/src/member-of-union.ts @@ -3,8 +3,7 @@ import type { UnionToIntersection } from "./union-to-intersection.js"; /** * Selects a single member of a union, or `never` for `never`. * - * Selection is unspecified and compiler-dependent. Unions have no meaningful - * order, so the selected member is not a meaningful "last" member. + * Selection is unspecified and may differ between compiler versions and programs. * * @see {@link UnionToIntersection} *