From f72d51d557d73683348c3be9e9e5064e525e9573 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:59:53 +0900 Subject: [PATCH 01/21] feat(types): add es-toolkit/types module with compile-time type utilities Introduce a declaration-only `es-toolkit/types` entrypoint for compile-time type utilities. v1 ships seven everyday helpers that TypeScript does not provide natively: ValueOf, Simplify, Merge, SetOptional, SetRequired, Writable, and NonEmptyArray. The module is packaged as declaration-only: publishConfig exposes it through the `types` condition (no runtime `default`), and postbuild removes the empty JS the build emits, so consumers receive only .d.ts/.d.mts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .scripts/postbuild.sh | 6 ++++++ package.json | 9 +++++++++ src/types/Merge.spec.ts | 16 ++++++++++++++++ src/types/Merge.ts | 19 +++++++++++++++++++ src/types/NonEmptyArray.spec.ts | 17 +++++++++++++++++ src/types/NonEmptyArray.ts | 20 ++++++++++++++++++++ src/types/SetOptional.spec.ts | 14 ++++++++++++++ src/types/SetOptional.ts | 17 +++++++++++++++++ src/types/SetRequired.spec.ts | 14 ++++++++++++++ src/types/SetRequired.ts | 17 +++++++++++++++++ src/types/Simplify.spec.ts | 15 +++++++++++++++ src/types/Simplify.ts | 16 ++++++++++++++++ src/types/ValueOf.spec.ts | 14 ++++++++++++++ src/types/ValueOf.ts | 14 ++++++++++++++ src/types/Writable.spec.ts | 14 ++++++++++++++ src/types/Writable.ts | 14 ++++++++++++++ src/types/index.ts | 7 +++++++ 17 files changed, 243 insertions(+) create mode 100644 src/types/Merge.spec.ts create mode 100644 src/types/Merge.ts create mode 100644 src/types/NonEmptyArray.spec.ts create mode 100644 src/types/NonEmptyArray.ts create mode 100644 src/types/SetOptional.spec.ts create mode 100644 src/types/SetOptional.ts create mode 100644 src/types/SetRequired.spec.ts create mode 100644 src/types/SetRequired.ts create mode 100644 src/types/Simplify.spec.ts create mode 100644 src/types/Simplify.ts create mode 100644 src/types/ValueOf.spec.ts create mode 100644 src/types/ValueOf.ts create mode 100644 src/types/Writable.spec.ts create mode 100644 src/types/Writable.ts create mode 100644 src/types/index.ts diff --git a/.scripts/postbuild.sh b/.scripts/postbuild.sh index ab67deab4..11b95cd07 100755 --- a/.scripts/postbuild.sh +++ b/.scripts/postbuild.sh @@ -43,6 +43,12 @@ for module in array server error compat fp function math map object predicate pr create_root_export $module done +# The types module is declaration-only. Drop the empty JS the build emits so the +# package ships only .d.ts/.d.mts (exposed via the "types" condition in publishConfig). +if [ -d dist/types ]; then + find dist/types -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -delete +fi + # Create compat directory mkdir -p compat diff --git a/package.json b/package.json index 2059bc903..3cd35d7c2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "./set": "./src/set/index.ts", "./string": "./src/string/index.ts", "./server": "./src/server/index.ts", + "./types": "./src/types/index.ts", "./util": "./src/util/index.ts", "./package.json": "./package.json" }, @@ -260,6 +261,14 @@ "default": "./dist/string/index.js" } }, + "./types": { + "import": { + "types": "./dist/types/index.d.mts" + }, + "require": { + "types": "./dist/types/index.d.ts" + } + }, "./util": { "import": { "types": "./dist/util/index.d.mts", diff --git a/src/types/Merge.spec.ts b/src/types/Merge.spec.ts new file mode 100644 index 000000000..b03b1fdd7 --- /dev/null +++ b/src/types/Merge.spec.ts @@ -0,0 +1,16 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { Merge } from './Merge'; + +describe('Merge', () => { + it('combines two object types', () => { + type A = { id: number }; + type B = { name: string }; + expectTypeOf>().toEqualTypeOf<{ id: number; name: string }>(); + }); + + it('overrides overlapping keys with the second type', () => { + type Base = { id: number; createdAt: string }; + type Override = { createdAt: Date }; + expectTypeOf>().toEqualTypeOf<{ id: number; createdAt: Date }>(); + }); +}); diff --git a/src/types/Merge.ts b/src/types/Merge.ts new file mode 100644 index 000000000..9d9cb7f16 --- /dev/null +++ b/src/types/Merge.ts @@ -0,0 +1,19 @@ +import type { Simplify } from './Simplify.ts'; + +/** + * Merges two object types, with the second type (`U`) overriding the first (`T`). + * + * Unlike a plain `T & U` intersection, overlapping keys are replaced instead of + * intersected, so changing a property's type just works (e.g. `string` -> `Date`) + * rather than collapsing to `never`. + * + * @template T - The base object type. + * @template U - The object type whose properties take precedence. + * + * @example + * type Base = { id: number; createdAt: string }; + * type Override = { createdAt: Date }; + * type Result = Merge; + * // => { id: number; createdAt: Date } + */ +export type Merge = Simplify & U>; diff --git a/src/types/NonEmptyArray.spec.ts b/src/types/NonEmptyArray.spec.ts new file mode 100644 index 000000000..2d0be0516 --- /dev/null +++ b/src/types/NonEmptyArray.spec.ts @@ -0,0 +1,17 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { NonEmptyArray } from './NonEmptyArray'; + +describe('NonEmptyArray', () => { + it('is a tuple with a required first element and a rest', () => { + expectTypeOf>().toEqualTypeOf<[number, ...number[]]>(); + }); + + it('resolves the first element to T, not T | undefined', () => { + expectTypeOf[0]>().toEqualTypeOf(); + }); + + it('does not accept an empty array', () => { + type IsEmptyAssignable = [] extends NonEmptyArray ? true : false; + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/types/NonEmptyArray.ts b/src/types/NonEmptyArray.ts new file mode 100644 index 000000000..726eba5c2 --- /dev/null +++ b/src/types/NonEmptyArray.ts @@ -0,0 +1,20 @@ +/** + * An array guaranteed to have at least one element. + * + * Modeled as a tuple with a required first element followed by a rest, so the + * type system knows the array is never empty. This lets accessors like the first + * element resolve to `T` instead of `T | undefined`. + * + * @template T - The type of the elements. + * + * @example + * const a: NonEmptyArray = [1]; // ok + * const b: NonEmptyArray = [1, 2, 3]; // ok + * const c: NonEmptyArray = []; // error: empty array not allowed + * + * @example + * function first(arr: NonEmptyArray): T { + * return arr[0]; // T, not T | undefined + * } + */ +export type NonEmptyArray = [T, ...T[]]; diff --git a/src/types/SetOptional.spec.ts b/src/types/SetOptional.spec.ts new file mode 100644 index 000000000..2775578ec --- /dev/null +++ b/src/types/SetOptional.spec.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { SetOptional } from './SetOptional'; + +describe('SetOptional', () => { + it('makes the given keys optional', () => { + type User = { id: number; name: string; avatar: string }; + expectTypeOf>().toEqualTypeOf<{ id: number; name: string; avatar?: string }>(); + }); + + it('leaves other keys unchanged', () => { + type User = { id: number; name: string }; + expectTypeOf>().toEqualTypeOf<{ id: number; name?: string }>(); + }); +}); diff --git a/src/types/SetOptional.ts b/src/types/SetOptional.ts new file mode 100644 index 000000000..32a2c7329 --- /dev/null +++ b/src/types/SetOptional.ts @@ -0,0 +1,17 @@ +import type { Simplify } from './Simplify.ts'; + +/** + * Makes the given keys `K` of `T` optional, leaving the rest unchanged. + * + * Like the built-in `Partial`, but scoped to specific keys instead of the whole + * object. Useful for "create" inputs where a few fields have defaults. + * + * @template T - The object type to transform. + * @template K - The keys to make optional. + * + * @example + * type User = { id: number; name: string; avatar: string }; + * type NewUser = SetOptional; + * // => { id: number; name: string; avatar?: string } + */ +export type SetOptional = Simplify & Partial>>; diff --git a/src/types/SetRequired.spec.ts b/src/types/SetRequired.spec.ts new file mode 100644 index 000000000..9a793e177 --- /dev/null +++ b/src/types/SetRequired.spec.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { SetRequired } from './SetRequired'; + +describe('SetRequired', () => { + it('makes the given optional keys required', () => { + type User = { id: number; name: string; avatar?: string }; + expectTypeOf>().toEqualTypeOf<{ id: number; name: string; avatar: string }>(); + }); + + it('leaves other keys unchanged', () => { + type User = { id?: number; name?: string }; + expectTypeOf>().toEqualTypeOf<{ id: number; name?: string }>(); + }); +}); diff --git a/src/types/SetRequired.ts b/src/types/SetRequired.ts new file mode 100644 index 000000000..958d2c1a9 --- /dev/null +++ b/src/types/SetRequired.ts @@ -0,0 +1,17 @@ +import type { Simplify } from './Simplify.ts'; + +/** + * Makes the given keys `K` of `T` required, leaving the rest unchanged. + * + * Like the built-in `Required`, but scoped to specific keys instead of the whole + * object. Useful when a context guarantees some optional fields are present. + * + * @template T - The object type to transform. + * @template K - The keys to make required. + * + * @example + * type User = { id: number; name: string; avatar?: string }; + * type ProfileUser = SetRequired; + * // => { id: number; name: string; avatar: string } + */ +export type SetRequired = Simplify & Required>>; diff --git a/src/types/Simplify.spec.ts b/src/types/Simplify.spec.ts new file mode 100644 index 000000000..e6e30e65b --- /dev/null +++ b/src/types/Simplify.spec.ts @@ -0,0 +1,15 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { Simplify } from './Simplify'; + +describe('Simplify', () => { + it('flattens an intersection into a single object type', () => { + type A = { name: string }; + type B = { age: number }; + expectTypeOf>().toEqualTypeOf<{ name: string; age: number }>(); + }); + + it('preserves optional and readonly modifiers', () => { + type T = { readonly id: number; name?: string }; + expectTypeOf>().toEqualTypeOf<{ readonly id: number; name?: string }>(); + }); +}); diff --git a/src/types/Simplify.ts b/src/types/Simplify.ts new file mode 100644 index 000000000..c63d82241 --- /dev/null +++ b/src/types/Simplify.ts @@ -0,0 +1,16 @@ +/** + * Flattens an intersection or mapped type into a single, readable object type. + * + * Purely cosmetic: the resulting type is identical, but editors show the + * resolved shape (`{ a: 1; b: 2 }`) instead of `A & B`. Handy for cleaning up + * tooltips on composed types. Optional and readonly modifiers are preserved. + * + * @template T - The type to flatten. + * + * @example + * type A = { name: string }; + * type B = { age: number }; + * type User = Simplify; + * // hover => { name: string; age: number } (instead of A & B) + */ +export type Simplify = { [K in keyof T]: T[K] } & {}; diff --git a/src/types/ValueOf.spec.ts b/src/types/ValueOf.spec.ts new file mode 100644 index 000000000..1e2073447 --- /dev/null +++ b/src/types/ValueOf.spec.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ValueOf } from './ValueOf'; + +describe('ValueOf', () => { + it('creates a union of an object type values', () => { + type User = { id: number; name: string }; + expectTypeOf>().toEqualTypeOf(); + }); + + it('derives a literal union from a const object', () => { + type Status = { readonly IDLE: 'idle'; readonly LOADING: 'loading'; readonly ERROR: 'error' }; + expectTypeOf>().toEqualTypeOf<'idle' | 'loading' | 'error'>(); + }); +}); diff --git a/src/types/ValueOf.ts b/src/types/ValueOf.ts new file mode 100644 index 000000000..877622612 --- /dev/null +++ b/src/types/ValueOf.ts @@ -0,0 +1,14 @@ +/** + * Creates a union of all value types in `T`. + * + * The value-side counterpart to `keyof`: `keyof T` gives the keys, `ValueOf` + * gives the values. Handy for deriving a union from a `const` object. + * + * @template T - The object type to read values from. + * + * @example + * const STATUS = { IDLE: 'idle', ERROR: 'error' } as const; + * type Status = ValueOf; + * // => 'idle' | 'error' + */ +export type ValueOf = T[keyof T]; diff --git a/src/types/Writable.spec.ts b/src/types/Writable.spec.ts new file mode 100644 index 000000000..00559397a --- /dev/null +++ b/src/types/Writable.spec.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { Writable } from './Writable'; + +describe('Writable', () => { + it('removes readonly from all properties', () => { + type Config = { readonly host: string; readonly port: number }; + expectTypeOf>().toEqualTypeOf<{ host: string; port: number }>(); + }); + + it('leaves already-writable properties unchanged', () => { + type T = { a: number; readonly b: string }; + expectTypeOf>().toEqualTypeOf<{ a: number; b: string }>(); + }); +}); diff --git a/src/types/Writable.ts b/src/types/Writable.ts new file mode 100644 index 000000000..cf4c36b40 --- /dev/null +++ b/src/types/Writable.ts @@ -0,0 +1,14 @@ +/** + * Removes the `readonly` modifier from all properties of `T`. + * + * The inverse of the built-in `Readonly`. Handy for turning a `readonly` shape + * (e.g. from `as const`) back into a mutable one. + * + * @template T - The object type to make writable. + * + * @example + * type Config = { readonly host: string; readonly port: number }; + * type MutableConfig = Writable; + * // => { host: string; port: number } + */ +export type Writable = { -readonly [K in keyof T]: T[K] }; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 000000000..72704e82e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,7 @@ +export type { Merge } from './Merge.ts'; +export type { NonEmptyArray } from './NonEmptyArray.ts'; +export type { SetOptional } from './SetOptional.ts'; +export type { SetRequired } from './SetRequired.ts'; +export type { Simplify } from './Simplify.ts'; +export type { ValueOf } from './ValueOf.ts'; +export type { Writable } from './Writable.ts'; From b8d70331946fb39191ab62ac543c10d2b213953f Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:12:32 +0900 Subject: [PATCH 02/21] fix(types): add root types.d.ts shim for node10 module resolution attw fails under node10 resolution because it ignores the "exports" map and the types module has no root shim. Create a declaration-only root shim (types.d.ts without a runtime types.js) in postbuild, matching how the other modules are exposed to node10 consumers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .scripts/postbuild.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.scripts/postbuild.sh b/.scripts/postbuild.sh index 11b95cd07..dc8e49bf8 100755 --- a/.scripts/postbuild.sh +++ b/.scripts/postbuild.sh @@ -49,6 +49,10 @@ if [ -d dist/types ]; then find dist/types -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -delete fi +# node10 moduleResolution ignores "exports", so it needs a root shim like the other +# modules. Declaration-only, so only the .d.ts is created (no types.js counterpart). +echo "export * from './dist/types';" > types.d.ts + # Create compat directory mkdir -p compat From 02a6651cd72cebfda93352a3e9f7080f843bafac Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:57:08 +0900 Subject: [PATCH 03/21] feat(types): add DeepPartial Recursive Partial for nested object patches (config overrides, mocks). Arrays recurse into elements without becoming sparse, tuples keep their arity, and functions/Date/RegExp pass through unchanged. Single type parameter, no options. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepPartial.spec.ts | 61 +++++++++++++++++++++++++++++++++++ src/types/DeepPartial.ts | 35 ++++++++++++++++++++ src/types/index.ts | 1 + 3 files changed, 97 insertions(+) create mode 100644 src/types/DeepPartial.spec.ts create mode 100644 src/types/DeepPartial.ts diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts new file mode 100644 index 000000000..93cee3185 --- /dev/null +++ b/src/types/DeepPartial.spec.ts @@ -0,0 +1,61 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { DeepPartial } from './DeepPartial'; + +describe('DeepPartial', () => { + it('makes nested object properties optional recursively', () => { + type Config = { server: { host: string; port: number }; debug: boolean }; + expectTypeOf>().toEqualTypeOf<{ + server?: { host?: string; port?: number }; + debug?: boolean; + }>(); + }); + + it('accepts a partial patch but still rejects wrong value types', () => { + type Config = { server: { host: string; port: number } }; + const ok: DeepPartial = { server: { port: 8080 } }; + expectTypeOf(ok).toEqualTypeOf>(); + + // optionality must not weaken value types + // @ts-expect-error port must still be a number + const bad: DeepPartial = { server: { port: '8080' } }; + expectTypeOf(bad).toEqualTypeOf>(); + }); + + it('recurses into array elements without making them sparse', () => { + type T = { users: Array<{ name: string; age: number }> }; + expectTypeOf>().toEqualTypeOf<{ users?: Array<{ name?: string; age?: number }> }>(); + + // incomplete elements are allowed, holes are not + const ok: DeepPartial = { users: [{ name: 'kim' }] }; + expectTypeOf(ok).toEqualTypeOf>(); + + // @ts-expect-error sparse arrays stay illegal + const bad: DeepPartial = { users: [undefined] }; + expectTypeOf(bad).toEqualTypeOf>(); + }); + + it('preserves tuple shape and arity', () => { + type T = { pair: [number, { x: string }] }; + expectTypeOf>().toEqualTypeOf<{ pair?: [number, { x?: string }] }>(); + + // @ts-expect-error tuple elements do not become optional + const bad: DeepPartial = { pair: [1] }; + expectTypeOf(bad).toEqualTypeOf>(); + }); + + it('keeps functions, Date, and RegExp unchanged', () => { + expectTypeOf void>>().toEqualTypeOf<() => void>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it('recurses into Map and Set', () => { + expectTypeOf>>().toEqualTypeOf>(); + expectTypeOf>>().toEqualTypeOf>(); + }); + + it('makes Record values partial', () => { + type T = Record; + expectTypeOf>().toEqualTypeOf>>(); + }); +}); diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts new file mode 100644 index 000000000..b6b95b005 --- /dev/null +++ b/src/types/DeepPartial.ts @@ -0,0 +1,35 @@ +/** + * Makes all properties of `T` optional recursively, unlike the built-in + * `Partial` which only affects the first level. + * + * Designed for nested object patches (config overrides, mock fixtures, partial + * form state). Recurses into plain objects, arrays/tuples (element types only — + * elements do not become sparse), `Map`, and `Set`. Functions, `Date`, and + * `RegExp` pass through unchanged. + * + * @template T - The type to make deeply optional. + * + * @example + * type Config = { server: { host: string; port: number }; debug: boolean }; + * type ConfigPatch = DeepPartial; + * // => { server?: { host?: string; port?: number }; debug?: boolean } + * + * const patch: ConfigPatch = { server: { port: 8080 } }; // ok, host omitted + */ +export type DeepPartial = T extends (...args: any[]) => unknown + ? T + : T extends Date | RegExp + ? T + : T extends Map + ? Map, DeepPartial> + : T extends ReadonlyMap + ? ReadonlyMap, DeepPartial> + : T extends Set + ? Set> + : T extends ReadonlySet + ? ReadonlySet> + : T extends readonly unknown[] + ? { [K in keyof T]: DeepPartial } + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; diff --git a/src/types/index.ts b/src/types/index.ts index 72704e82e..e0e7bff9a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +export type { DeepPartial } from './DeepPartial.ts'; export type { Merge } from './Merge.ts'; export type { NonEmptyArray } from './NonEmptyArray.ts'; export type { SetOptional } from './SetOptional.ts'; From 7375a07b44f2ec15fe65012e8e2274f693577903 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:09:57 +0900 Subject: [PATCH 04/21] refactor(types): simplify DeepPartial to essential branches Drop the Map/Set handling: patch-shaped types are JSON-like in practice, and the extra branches doubled the type for a tail case. The chain now reads pass-through -> array -> object -> primitive. Support can be added back non-breakingly if requested. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepPartial.spec.ts | 5 ----- src/types/DeepPartial.ts | 27 ++++++++------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index 93cee3185..8580794f5 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -49,11 +49,6 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); - it('recurses into Map and Set', () => { - expectTypeOf>>().toEqualTypeOf>(); - expectTypeOf>>().toEqualTypeOf>(); - }); - it('makes Record values partial', () => { type T = Record; expectTypeOf>().toEqualTypeOf>>(); diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index b6b95b005..e75bbd613 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -3,9 +3,8 @@ * `Partial` which only affects the first level. * * Designed for nested object patches (config overrides, mock fixtures, partial - * form state). Recurses into plain objects, arrays/tuples (element types only — - * elements do not become sparse), `Map`, and `Set`. Functions, `Date`, and - * `RegExp` pass through unchanged. + * form state). Arrays and tuples recurse into their elements without becoming + * sparse. Functions, `Date`, and `RegExp` pass through unchanged. * * @template T - The type to make deeply optional. * @@ -16,20 +15,10 @@ * * const patch: ConfigPatch = { server: { port: 8080 } }; // ok, host omitted */ -export type DeepPartial = T extends (...args: any[]) => unknown +export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp ? T - : T extends Date | RegExp - ? T - : T extends Map - ? Map, DeepPartial> - : T extends ReadonlyMap - ? ReadonlyMap, DeepPartial> - : T extends Set - ? Set> - : T extends ReadonlySet - ? ReadonlySet> - : T extends readonly unknown[] - ? { [K in keyof T]: DeepPartial } - : T extends object - ? { [K in keyof T]?: DeepPartial } - : T; + : T extends readonly unknown[] + ? { [K in keyof T]: DeepPartial } + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; From 90d3e4b4790b866d253a5789e4077d0a5d663715 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:49:42 +0900 Subject: [PATCH 05/21] feat(types): add DeepReadonly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recursive Readonly for immutable state and no-mutation function parameters. Same skeleton as DeepPartial: arrays/tuples become readonly recursively, functions/Date/RegExp pass through. Map/Set traversal intentionally omitted — sampled real-world usage showed 0/23 resolvable Deep* arguments contain them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepReadonly.spec.ts | 45 ++++++++++++++++++++++++++++++++++ src/types/DeepReadonly.ts | 23 +++++++++++++++++ src/types/index.ts | 1 + 3 files changed, 69 insertions(+) create mode 100644 src/types/DeepReadonly.spec.ts create mode 100644 src/types/DeepReadonly.ts diff --git a/src/types/DeepReadonly.spec.ts b/src/types/DeepReadonly.spec.ts new file mode 100644 index 000000000..f5b711925 --- /dev/null +++ b/src/types/DeepReadonly.spec.ts @@ -0,0 +1,45 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { DeepReadonly } from './DeepReadonly'; + +describe('DeepReadonly', () => { + it('makes nested object properties readonly recursively', () => { + type State = { user: { name: string; active: boolean } }; + expectTypeOf>().toEqualTypeOf<{ + readonly user: { readonly name: string; readonly active: boolean }; + }>(); + }); + + it('rejects mutation at any depth', () => { + type State = { user: { name: string } }; + const state = { user: { name: 'kim' } } as DeepReadonly; + + // @ts-expect-error nested properties are readonly + state.user.name = 'lee'; + expectTypeOf(state).toEqualTypeOf>(); + }); + + it('makes arrays readonly and recurses into elements', () => { + type T = { users: Array<{ name: string }> }; + expectTypeOf>().toEqualTypeOf<{ + readonly users: ReadonlyArray<{ readonly name: string }>; + }>(); + }); + + it('preserves tuple shape', () => { + type T = { pair: [number, { x: string }] }; + expectTypeOf>().toEqualTypeOf<{ + readonly pair: readonly [number, { readonly x: string }]; + }>(); + }); + + it('keeps functions, Date, and RegExp unchanged', () => { + expectTypeOf void>>().toEqualTypeOf<() => void>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); + + it('makes Record values readonly', () => { + type T = Record; + expectTypeOf>().toEqualTypeOf>>(); + }); +}); diff --git a/src/types/DeepReadonly.ts b/src/types/DeepReadonly.ts new file mode 100644 index 000000000..639313405 --- /dev/null +++ b/src/types/DeepReadonly.ts @@ -0,0 +1,23 @@ +/** + * Makes all properties of `T` readonly recursively, unlike the built-in + * `Readonly` which only affects the first level. + * + * Designed for immutable state and function parameters that must not be + * mutated. Arrays and tuples become readonly as well. Functions, `Date`, and + * `RegExp` pass through unchanged. + * + * @template T - The type to make deeply readonly. + * + * @example + * type State = { user: { name: string; tags: string[] } }; + * type FrozenState = DeepReadonly; + * // => { readonly user: { readonly name: string; readonly tags: readonly string[] } } + * + * declare const state: FrozenState; + * state.user.name = 'x'; // error: name is readonly + */ +export type DeepReadonly = T extends ((...args: any[]) => unknown) | Date | RegExp + ? T + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; diff --git a/src/types/index.ts b/src/types/index.ts index e0e7bff9a..9dcc28c5b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export type { DeepPartial } from './DeepPartial.ts'; +export type { DeepReadonly } from './DeepReadonly.ts'; export type { Merge } from './Merge.ts'; export type { NonEmptyArray } from './NonEmptyArray.ts'; export type { SetOptional } from './SetOptional.ts'; From dcf0bf10a241c07babeee67da01b085ee4f640d1 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:17:27 +0900 Subject: [PATCH 06/21] feat(types): recurse into Map and Set in deep utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without these branches DeepReadonly leaves Map/Set mutable (.set/.add survive) and DeepPartial lets a plain {} pass as a Map while demanding complete values inside — both break the utility's promise. DeepReadonly folds Map into the ReadonlyMap check (structural), DeepPartial keeps the mutable/readonly split. Chains are formatted flat, one case per line, for readability. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepPartial.spec.ts | 12 ++++++++++++ src/types/DeepPartial.ts | 20 ++++++++++++-------- src/types/DeepReadonly.spec.ts | 13 +++++++++++++ src/types/DeepReadonly.ts | 17 ++++++++++------- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index 8580794f5..ab52be2ed 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -49,6 +49,18 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); + it('recurses into Map and Set contents', () => { + expectTypeOf>>().toEqualTypeOf< + Map + >(); + expectTypeOf>>().toEqualTypeOf>(); + + type Config = { routes: Map }; + // @ts-expect-error a plain object must not pass as a Map + const bad: DeepPartial = { routes: {} }; + expectTypeOf(bad).toEqualTypeOf>(); + }); + it('makes Record values partial', () => { type T = Record; expectTypeOf>().toEqualTypeOf>>(); diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index e75bbd613..28e98b32b 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -4,7 +4,8 @@ * * Designed for nested object patches (config overrides, mock fixtures, partial * form state). Arrays and tuples recurse into their elements without becoming - * sparse. Functions, `Date`, and `RegExp` pass through unchanged. + * sparse, and `Map`/`Set` recurse into their contents. Functions, `Date`, and + * `RegExp` pass through unchanged. * * @template T - The type to make deeply optional. * @@ -15,10 +16,13 @@ * * const patch: ConfigPatch = { server: { port: 8080 } }; // ok, host omitted */ -export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp - ? T - : T extends readonly unknown[] - ? { [K in keyof T]: DeepPartial } - : T extends object - ? { [K in keyof T]?: DeepPartial } - : T; +// prettier-ignore +export type DeepPartial = + T extends ((...args: any[]) => unknown) | Date | RegExp ? T : + T extends Map ? Map, DeepPartial> : + T extends ReadonlyMap ? ReadonlyMap, DeepPartial> : + T extends Set ? Set> : + T extends ReadonlySet ? ReadonlySet> : + T extends readonly unknown[] ? { [K in keyof T]: DeepPartial } : + T extends object ? { [K in keyof T]?: DeepPartial } : + T; diff --git a/src/types/DeepReadonly.spec.ts b/src/types/DeepReadonly.spec.ts index f5b711925..705a89f76 100644 --- a/src/types/DeepReadonly.spec.ts +++ b/src/types/DeepReadonly.spec.ts @@ -38,6 +38,19 @@ describe('DeepReadonly', () => { expectTypeOf>().toEqualTypeOf(); }); + it('converts Map and Set to their readonly counterparts', () => { + expectTypeOf>>().toEqualTypeOf< + ReadonlyMap + >(); + expectTypeOf>>().toEqualTypeOf>(); + + type State = { cache: Map }; + const state = { cache: new Map() } as DeepReadonly; + // @ts-expect-error mutation methods disappear on ReadonlyMap + state.cache.set('k', 1); + expectTypeOf(state).toEqualTypeOf>(); + }); + it('makes Record values readonly', () => { type T = Record; expectTypeOf>().toEqualTypeOf>>(); diff --git a/src/types/DeepReadonly.ts b/src/types/DeepReadonly.ts index 639313405..a88c77eef 100644 --- a/src/types/DeepReadonly.ts +++ b/src/types/DeepReadonly.ts @@ -3,8 +3,9 @@ * `Readonly` which only affects the first level. * * Designed for immutable state and function parameters that must not be - * mutated. Arrays and tuples become readonly as well. Functions, `Date`, and - * `RegExp` pass through unchanged. + * mutated. Arrays and tuples become readonly, and `Map`/`Set` become + * `ReadonlyMap`/`ReadonlySet` so mutation methods like `set` and `add` + * disappear. Functions, `Date`, and `RegExp` pass through unchanged. * * @template T - The type to make deeply readonly. * @@ -16,8 +17,10 @@ * declare const state: FrozenState; * state.user.name = 'x'; // error: name is readonly */ -export type DeepReadonly = T extends ((...args: any[]) => unknown) | Date | RegExp - ? T - : T extends object - ? { readonly [K in keyof T]: DeepReadonly } - : T; +// prettier-ignore +export type DeepReadonly = + T extends ((...args: any[]) => unknown) | Date | RegExp ? T : + T extends ReadonlyMap ? ReadonlyMap, DeepReadonly> : + T extends ReadonlySet ? ReadonlySet> : + T extends object ? { readonly [K in keyof T]: DeepReadonly } : + T; From 8b2e3ad01a5fa11018a24174959d4eb395af3474 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:32:22 +0900 Subject: [PATCH 07/21] refactor(types): align DeepPartial recursion boundary with merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge/toMerged recurse into plain objects and arrays only; Map and Set are replaced wholesale. Recursing into their contents at the type level let an incomplete Map replace a complete one through merge, producing values that lie about their type. Map/Set (and their readonly variants) now pass through, so they must be provided complete — exactly what merge does with them. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepPartial.spec.ts | 16 ++++++++++------ src/types/DeepPartial.ts | 12 +++++------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index ab52be2ed..72f8b5c03 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -49,16 +49,20 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); - it('recurses into Map and Set contents', () => { + it('requires complete Map and Set values, matching merge which replaces them wholesale', () => { expectTypeOf>>().toEqualTypeOf< - Map + Map >(); - expectTypeOf>>().toEqualTypeOf>(); + expectTypeOf>>().toEqualTypeOf>(); - type Config = { routes: Map }; - // @ts-expect-error a plain object must not pass as a Map - const bad: DeepPartial = { routes: {} }; + type Config = { routes: Map }; + // @ts-expect-error incomplete Map contents are rejected + const bad: DeepPartial = { routes: new Map([['home', { handler: 'h' }]]) }; expectTypeOf(bad).toEqualTypeOf>(); + + // @ts-expect-error a plain object must not pass as a Map + const bad2: DeepPartial = { routes: {} }; + expectTypeOf(bad2).toEqualTypeOf>(); }); it('makes Record values partial', () => { diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index 28e98b32b..f938d71d8 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -3,9 +3,10 @@ * `Partial` which only affects the first level. * * Designed for nested object patches (config overrides, mock fixtures, partial - * form state). Arrays and tuples recurse into their elements without becoming - * sparse, and `Map`/`Set` recurse into their contents. Functions, `Date`, and - * `RegExp` pass through unchanged. + * form state) applied with `merge`/`toMerged`. The recursion boundary matches + * their runtime behavior: plain objects and arrays/tuples recurse (arrays do + * not become sparse), while values `merge` replaces wholesale — `Map`, `Set`, + * functions, `Date`, `RegExp` — must be provided complete. * * @template T - The type to make deeply optional. * @@ -19,10 +20,7 @@ // prettier-ignore export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp ? T : - T extends Map ? Map, DeepPartial> : - T extends ReadonlyMap ? ReadonlyMap, DeepPartial> : - T extends Set ? Set> : - T extends ReadonlySet ? ReadonlySet> : + T extends Map | ReadonlyMap | Set | ReadonlySet ? T : T extends readonly unknown[] ? { [K in keyof T]: DeepPartial } : T extends object ? { [K in keyof T]?: DeepPartial } : T; From af239cb9335c8c92bb2d54c9e38e64c79c43bcca Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:38:23 +0900 Subject: [PATCH 08/21] feat(types): recurse into Map and Set contents in DeepPartial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settle on one broad structural boundary for the deep utilities: containers (objects, arrays/tuples, Map/Set) recurse, atomic built-ins (functions, Date, RegExp) pass through — the same traversal cloneDeep performs and the same boundary DeepReadonly uses. The merge caveat (Map/Set are replaced wholesale at runtime) is documented on the type instead of narrowing it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/DeepPartial.spec.ts | 16 ++++++---------- src/types/DeepPartial.ts | 13 ++++++++----- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index 72f8b5c03..ab52be2ed 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -49,20 +49,16 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); - it('requires complete Map and Set values, matching merge which replaces them wholesale', () => { + it('recurses into Map and Set contents', () => { expectTypeOf>>().toEqualTypeOf< - Map + Map >(); - expectTypeOf>>().toEqualTypeOf>(); - - type Config = { routes: Map }; - // @ts-expect-error incomplete Map contents are rejected - const bad: DeepPartial = { routes: new Map([['home', { handler: 'h' }]]) }; - expectTypeOf(bad).toEqualTypeOf>(); + expectTypeOf>>().toEqualTypeOf>(); + type Config = { routes: Map }; // @ts-expect-error a plain object must not pass as a Map - const bad2: DeepPartial = { routes: {} }; - expectTypeOf(bad2).toEqualTypeOf>(); + const bad: DeepPartial = { routes: {} }; + expectTypeOf(bad).toEqualTypeOf>(); }); it('makes Record values partial', () => { diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index f938d71d8..8b98e4044 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -3,10 +3,10 @@ * `Partial` which only affects the first level. * * Designed for nested object patches (config overrides, mock fixtures, partial - * form state) applied with `merge`/`toMerged`. The recursion boundary matches - * their runtime behavior: plain objects and arrays/tuples recurse (arrays do - * not become sparse), while values `merge` replaces wholesale — `Map`, `Set`, - * functions, `Date`, `RegExp` — must be provided complete. + * form state). Recurses into plain objects, arrays/tuples (elements do not + * become sparse), and `Map`/`Set` contents. Functions, `Date`, and `RegExp` + * pass through unchanged. Note that `merge` replaces `Map`/`Set` wholesale at + * runtime, so applying partial `Map`/`Set` contents needs a custom applier. * * @template T - The type to make deeply optional. * @@ -20,7 +20,10 @@ // prettier-ignore export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp ? T : - T extends Map | ReadonlyMap | Set | ReadonlySet ? T : + T extends Map ? Map, DeepPartial> : + T extends ReadonlyMap ? ReadonlyMap, DeepPartial> : + T extends Set ? Set> : + T extends ReadonlySet ? ReadonlySet> : T extends readonly unknown[] ? { [K in keyof T]: DeepPartial } : T extends object ? { [K in keyof T]?: DeepPartial } : T; From 591a46ca9ca16c0f0cafe51506414d66890eaf34 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:42:27 +0900 Subject: [PATCH 09/21] test(types): trim specs to intended design decisions One it() per intent. Drop asserts already implied by whole-shape equality ("leaves other keys unchanged"), representation checks (NonEmptyArray's tuple form), and duplicate happy paths. Deep* specs already followed this rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/Merge.spec.ts | 12 +++--------- src/types/NonEmptyArray.spec.ts | 13 ++++++------- src/types/SetOptional.spec.ts | 7 +------ src/types/SetRequired.spec.ts | 7 +------ src/types/Simplify.spec.ts | 11 +++-------- src/types/ValueOf.spec.ts | 7 ++----- src/types/Writable.spec.ts | 5 ----- 7 files changed, 16 insertions(+), 46 deletions(-) diff --git a/src/types/Merge.spec.ts b/src/types/Merge.spec.ts index b03b1fdd7..9d1acb291 100644 --- a/src/types/Merge.spec.ts +++ b/src/types/Merge.spec.ts @@ -2,15 +2,9 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { Merge } from './Merge'; describe('Merge', () => { - it('combines two object types', () => { - type A = { id: number }; - type B = { name: string }; - expectTypeOf>().toEqualTypeOf<{ id: number; name: string }>(); - }); - - it('overrides overlapping keys with the second type', () => { + it('merges two object types, with the second overriding overlapping keys', () => { type Base = { id: number; createdAt: string }; - type Override = { createdAt: Date }; - expectTypeOf>().toEqualTypeOf<{ id: number; createdAt: Date }>(); + type Override = { createdAt: Date; extra: boolean }; + expectTypeOf>().toEqualTypeOf<{ id: number; createdAt: Date; extra: boolean }>(); }); }); diff --git a/src/types/NonEmptyArray.spec.ts b/src/types/NonEmptyArray.spec.ts index 2d0be0516..26a6b4f94 100644 --- a/src/types/NonEmptyArray.spec.ts +++ b/src/types/NonEmptyArray.spec.ts @@ -2,16 +2,15 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { NonEmptyArray } from './NonEmptyArray'; describe('NonEmptyArray', () => { - it('is a tuple with a required first element and a rest', () => { - expectTypeOf>().toEqualTypeOf<[number, ...number[]]>(); + it('guarantees at least one element', () => { + const ok: NonEmptyArray = [1]; + expectTypeOf(ok).toEqualTypeOf>(); + + type EmptyAssignable = [] extends NonEmptyArray ? true : false; + expectTypeOf().toEqualTypeOf(); }); it('resolves the first element to T, not T | undefined', () => { expectTypeOf[0]>().toEqualTypeOf(); }); - - it('does not accept an empty array', () => { - type IsEmptyAssignable = [] extends NonEmptyArray ? true : false; - expectTypeOf().toEqualTypeOf(); - }); }); diff --git a/src/types/SetOptional.spec.ts b/src/types/SetOptional.spec.ts index 2775578ec..808aba0f8 100644 --- a/src/types/SetOptional.spec.ts +++ b/src/types/SetOptional.spec.ts @@ -2,13 +2,8 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { SetOptional } from './SetOptional'; describe('SetOptional', () => { - it('makes the given keys optional', () => { + it('makes only the given keys optional', () => { type User = { id: number; name: string; avatar: string }; expectTypeOf>().toEqualTypeOf<{ id: number; name: string; avatar?: string }>(); }); - - it('leaves other keys unchanged', () => { - type User = { id: number; name: string }; - expectTypeOf>().toEqualTypeOf<{ id: number; name?: string }>(); - }); }); diff --git a/src/types/SetRequired.spec.ts b/src/types/SetRequired.spec.ts index 9a793e177..7096cb216 100644 --- a/src/types/SetRequired.spec.ts +++ b/src/types/SetRequired.spec.ts @@ -2,12 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { SetRequired } from './SetRequired'; describe('SetRequired', () => { - it('makes the given optional keys required', () => { - type User = { id: number; name: string; avatar?: string }; - expectTypeOf>().toEqualTypeOf<{ id: number; name: string; avatar: string }>(); - }); - - it('leaves other keys unchanged', () => { + it('makes only the given keys required', () => { type User = { id?: number; name?: string }; expectTypeOf>().toEqualTypeOf<{ id: number; name?: string }>(); }); diff --git a/src/types/Simplify.spec.ts b/src/types/Simplify.spec.ts index e6e30e65b..ee69cf3f1 100644 --- a/src/types/Simplify.spec.ts +++ b/src/types/Simplify.spec.ts @@ -2,14 +2,9 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { Simplify } from './Simplify'; describe('Simplify', () => { - it('flattens an intersection into a single object type', () => { + it('flattens an intersection while preserving optional and readonly modifiers', () => { type A = { name: string }; - type B = { age: number }; - expectTypeOf>().toEqualTypeOf<{ name: string; age: number }>(); - }); - - it('preserves optional and readonly modifiers', () => { - type T = { readonly id: number; name?: string }; - expectTypeOf>().toEqualTypeOf<{ readonly id: number; name?: string }>(); + type B = { readonly age?: number }; + expectTypeOf>().toEqualTypeOf<{ name: string; readonly age?: number }>(); }); }); diff --git a/src/types/ValueOf.spec.ts b/src/types/ValueOf.spec.ts index 1e2073447..5c94cd487 100644 --- a/src/types/ValueOf.spec.ts +++ b/src/types/ValueOf.spec.ts @@ -2,12 +2,9 @@ import { describe, expectTypeOf, it } from 'vitest'; import type { ValueOf } from './ValueOf'; describe('ValueOf', () => { - it('creates a union of an object type values', () => { - type User = { id: number; name: string }; - expectTypeOf>().toEqualTypeOf(); - }); + it('creates a union of all value types', () => { + expectTypeOf>().toEqualTypeOf(); - it('derives a literal union from a const object', () => { type Status = { readonly IDLE: 'idle'; readonly LOADING: 'loading'; readonly ERROR: 'error' }; expectTypeOf>().toEqualTypeOf<'idle' | 'loading' | 'error'>(); }); diff --git a/src/types/Writable.spec.ts b/src/types/Writable.spec.ts index 00559397a..a3dc6d76d 100644 --- a/src/types/Writable.spec.ts +++ b/src/types/Writable.spec.ts @@ -6,9 +6,4 @@ describe('Writable', () => { type Config = { readonly host: string; readonly port: number }; expectTypeOf>().toEqualTypeOf<{ host: string; port: number }>(); }); - - it('leaves already-writable properties unchanged', () => { - type T = { a: number; readonly b: string }; - expectTypeOf>().toEqualTypeOf<{ a: number; b: string }>(); - }); }); From bc25b3453f9fb464737f18abeb4473e7925de438 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Wed, 8 Jul 2026 11:50:13 +0900 Subject: [PATCH 10/21] docs(types): tighten JSDoc to essentials --- src/types/DeepPartial.ts | 8 +++----- src/types/DeepReadonly.ts | 7 +++---- src/types/Simplify.ts | 6 +++--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index 8b98e4044..765521f4f 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -2,11 +2,9 @@ * Makes all properties of `T` optional recursively, unlike the built-in * `Partial` which only affects the first level. * - * Designed for nested object patches (config overrides, mock fixtures, partial - * form state). Recurses into plain objects, arrays/tuples (elements do not - * become sparse), and `Map`/`Set` contents. Functions, `Date`, and `RegExp` - * pass through unchanged. Note that `merge` replaces `Map`/`Set` wholesale at - * runtime, so applying partial `Map`/`Set` contents needs a custom applier. + * Recurses into plain objects, arrays/tuples (elements do not become sparse), + * and `Map`/`Set` contents. Functions, `Date`, and `RegExp` pass through + * unchanged. * * @template T - The type to make deeply optional. * diff --git a/src/types/DeepReadonly.ts b/src/types/DeepReadonly.ts index a88c77eef..c80bd2aa6 100644 --- a/src/types/DeepReadonly.ts +++ b/src/types/DeepReadonly.ts @@ -2,10 +2,9 @@ * Makes all properties of `T` readonly recursively, unlike the built-in * `Readonly` which only affects the first level. * - * Designed for immutable state and function parameters that must not be - * mutated. Arrays and tuples become readonly, and `Map`/`Set` become - * `ReadonlyMap`/`ReadonlySet` so mutation methods like `set` and `add` - * disappear. Functions, `Date`, and `RegExp` pass through unchanged. + * Arrays and tuples become readonly, and `Map`/`Set` become + * `ReadonlyMap`/`ReadonlySet`. Functions, `Date`, and `RegExp` pass through + * unchanged. * * @template T - The type to make deeply readonly. * diff --git a/src/types/Simplify.ts b/src/types/Simplify.ts index c63d82241..a3da42471 100644 --- a/src/types/Simplify.ts +++ b/src/types/Simplify.ts @@ -1,9 +1,9 @@ /** * Flattens an intersection or mapped type into a single, readable object type. * - * Purely cosmetic: the resulting type is identical, but editors show the - * resolved shape (`{ a: 1; b: 2 }`) instead of `A & B`. Handy for cleaning up - * tooltips on composed types. Optional and readonly modifiers are preserved. + * The result is equivalent for plain object types, but editors show the + * resolved shape (`{ a: 1; b: 2 }`) instead of `A & B`. Optional and readonly + * modifiers are preserved. * * @template T - The type to flatten. * From b53a7df89e4334a2e058075846c2e00484c02ac0 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Wed, 8 Jul 2026 11:51:00 +0900 Subject: [PATCH 11/21] test(types): drop assertions implied by toEqualTypeOf equality --- src/types/DeepPartial.spec.ts | 28 ---------------------------- src/types/DeepReadonly.spec.ts | 15 --------------- 2 files changed, 43 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index ab52be2ed..944e1ed7a 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -10,37 +10,14 @@ describe('DeepPartial', () => { }>(); }); - it('accepts a partial patch but still rejects wrong value types', () => { - type Config = { server: { host: string; port: number } }; - const ok: DeepPartial = { server: { port: 8080 } }; - expectTypeOf(ok).toEqualTypeOf>(); - - // optionality must not weaken value types - // @ts-expect-error port must still be a number - const bad: DeepPartial = { server: { port: '8080' } }; - expectTypeOf(bad).toEqualTypeOf>(); - }); - it('recurses into array elements without making them sparse', () => { type T = { users: Array<{ name: string; age: number }> }; expectTypeOf>().toEqualTypeOf<{ users?: Array<{ name?: string; age?: number }> }>(); - - // incomplete elements are allowed, holes are not - const ok: DeepPartial = { users: [{ name: 'kim' }] }; - expectTypeOf(ok).toEqualTypeOf>(); - - // @ts-expect-error sparse arrays stay illegal - const bad: DeepPartial = { users: [undefined] }; - expectTypeOf(bad).toEqualTypeOf>(); }); it('preserves tuple shape and arity', () => { type T = { pair: [number, { x: string }] }; expectTypeOf>().toEqualTypeOf<{ pair?: [number, { x?: string }] }>(); - - // @ts-expect-error tuple elements do not become optional - const bad: DeepPartial = { pair: [1] }; - expectTypeOf(bad).toEqualTypeOf>(); }); it('keeps functions, Date, and RegExp unchanged', () => { @@ -54,11 +31,6 @@ describe('DeepPartial', () => { Map >(); expectTypeOf>>().toEqualTypeOf>(); - - type Config = { routes: Map }; - // @ts-expect-error a plain object must not pass as a Map - const bad: DeepPartial = { routes: {} }; - expectTypeOf(bad).toEqualTypeOf>(); }); it('makes Record values partial', () => { diff --git a/src/types/DeepReadonly.spec.ts b/src/types/DeepReadonly.spec.ts index 705a89f76..02adecf43 100644 --- a/src/types/DeepReadonly.spec.ts +++ b/src/types/DeepReadonly.spec.ts @@ -9,15 +9,6 @@ describe('DeepReadonly', () => { }>(); }); - it('rejects mutation at any depth', () => { - type State = { user: { name: string } }; - const state = { user: { name: 'kim' } } as DeepReadonly; - - // @ts-expect-error nested properties are readonly - state.user.name = 'lee'; - expectTypeOf(state).toEqualTypeOf>(); - }); - it('makes arrays readonly and recurses into elements', () => { type T = { users: Array<{ name: string }> }; expectTypeOf>().toEqualTypeOf<{ @@ -43,12 +34,6 @@ describe('DeepReadonly', () => { ReadonlyMap >(); expectTypeOf>>().toEqualTypeOf>(); - - type State = { cache: Map }; - const state = { cache: new Map() } as DeepReadonly; - // @ts-expect-error mutation methods disappear on ReadonlyMap - state.cache.set('k', 1); - expectTypeOf(state).toEqualTypeOf>(); }); it('makes Record values readonly', () => { From 2d505a646a4e1d0f972f581bf39eca1d6bb0dfc8 Mon Sep 17 00:00:00 2001 From: Dayong Lee <50468628+dayongkr@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:52:35 +0900 Subject: [PATCH 12/21] docs(types): tighten JSDoc on remaining type utilities Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/Merge.ts | 5 +---- src/types/NonEmptyArray.ts | 15 +++------------ src/types/SetOptional.ts | 4 +--- src/types/SetRequired.ts | 4 +--- src/types/ValueOf.ts | 5 +---- src/types/Writable.ts | 4 +--- 6 files changed, 8 insertions(+), 29 deletions(-) diff --git a/src/types/Merge.ts b/src/types/Merge.ts index 9d9cb7f16..5ce7b45b6 100644 --- a/src/types/Merge.ts +++ b/src/types/Merge.ts @@ -2,10 +2,7 @@ import type { Simplify } from './Simplify.ts'; /** * Merges two object types, with the second type (`U`) overriding the first (`T`). - * - * Unlike a plain `T & U` intersection, overlapping keys are replaced instead of - * intersected, so changing a property's type just works (e.g. `string` -> `Date`) - * rather than collapsing to `never`. + * Unlike `T & U`, overlapping keys are replaced instead of intersected. * * @template T - The base object type. * @template U - The object type whose properties take precedence. diff --git a/src/types/NonEmptyArray.ts b/src/types/NonEmptyArray.ts index 726eba5c2..72d630b64 100644 --- a/src/types/NonEmptyArray.ts +++ b/src/types/NonEmptyArray.ts @@ -1,20 +1,11 @@ /** * An array guaranteed to have at least one element. - * - * Modeled as a tuple with a required first element followed by a rest, so the - * type system knows the array is never empty. This lets accessors like the first - * element resolve to `T` instead of `T | undefined`. + * The first element resolves to `T` instead of `T | undefined`. * * @template T - The type of the elements. * * @example - * const a: NonEmptyArray = [1]; // ok - * const b: NonEmptyArray = [1, 2, 3]; // ok - * const c: NonEmptyArray = []; // error: empty array not allowed - * - * @example - * function first(arr: NonEmptyArray): T { - * return arr[0]; // T, not T | undefined - * } + * const a: NonEmptyArray = [1, 2, 3]; // ok + * const b: NonEmptyArray = []; // error: empty array not allowed */ export type NonEmptyArray = [T, ...T[]]; diff --git a/src/types/SetOptional.ts b/src/types/SetOptional.ts index 32a2c7329..7ad6547cc 100644 --- a/src/types/SetOptional.ts +++ b/src/types/SetOptional.ts @@ -2,9 +2,7 @@ import type { Simplify } from './Simplify.ts'; /** * Makes the given keys `K` of `T` optional, leaving the rest unchanged. - * - * Like the built-in `Partial`, but scoped to specific keys instead of the whole - * object. Useful for "create" inputs where a few fields have defaults. + * Like the built-in `Partial`, but scoped to specific keys. * * @template T - The object type to transform. * @template K - The keys to make optional. diff --git a/src/types/SetRequired.ts b/src/types/SetRequired.ts index 958d2c1a9..4728f47f2 100644 --- a/src/types/SetRequired.ts +++ b/src/types/SetRequired.ts @@ -2,9 +2,7 @@ import type { Simplify } from './Simplify.ts'; /** * Makes the given keys `K` of `T` required, leaving the rest unchanged. - * - * Like the built-in `Required`, but scoped to specific keys instead of the whole - * object. Useful when a context guarantees some optional fields are present. + * Like the built-in `Required`, but scoped to specific keys. * * @template T - The object type to transform. * @template K - The keys to make required. diff --git a/src/types/ValueOf.ts b/src/types/ValueOf.ts index 877622612..1aea48ebe 100644 --- a/src/types/ValueOf.ts +++ b/src/types/ValueOf.ts @@ -1,8 +1,5 @@ /** - * Creates a union of all value types in `T`. - * - * The value-side counterpart to `keyof`: `keyof T` gives the keys, `ValueOf` - * gives the values. Handy for deriving a union from a `const` object. + * Creates a union of all value types in `T`. The value-side counterpart to `keyof`. * * @template T - The object type to read values from. * diff --git a/src/types/Writable.ts b/src/types/Writable.ts index cf4c36b40..bdfed74ac 100644 --- a/src/types/Writable.ts +++ b/src/types/Writable.ts @@ -1,8 +1,6 @@ /** * Removes the `readonly` modifier from all properties of `T`. - * - * The inverse of the built-in `Readonly`. Handy for turning a `readonly` shape - * (e.g. from `as const`) back into a mutable one. + * The inverse of the built-in `Readonly`. * * @template T - The object type to make writable. * From 76c883c7be4de5a8c34b3432bdfc8d68814028a5 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Wed, 8 Jul 2026 15:08:53 +0900 Subject: [PATCH 13/21] test(types): cover Record value extraction in ValueOf --- src/types/ValueOf.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types/ValueOf.spec.ts b/src/types/ValueOf.spec.ts index 5c94cd487..58901bf35 100644 --- a/src/types/ValueOf.spec.ts +++ b/src/types/ValueOf.spec.ts @@ -8,4 +8,8 @@ describe('ValueOf', () => { type Status = { readonly IDLE: 'idle'; readonly LOADING: 'loading'; readonly ERROR: 'error' }; expectTypeOf>().toEqualTypeOf<'idle' | 'loading' | 'error'>(); }); + + it('resolves a Record to its value type', () => { + expectTypeOf>>().toEqualTypeOf(); + }); }); From a50f2b78d74b8c685824802978ed5d6d5f5ba9de Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Wed, 8 Jul 2026 15:40:39 +0900 Subject: [PATCH 14/21] docs(types): drop modifier-preservation note from Simplify --- src/types/Simplify.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/types/Simplify.ts b/src/types/Simplify.ts index a3da42471..6f4dd4add 100644 --- a/src/types/Simplify.ts +++ b/src/types/Simplify.ts @@ -2,8 +2,7 @@ * Flattens an intersection or mapped type into a single, readable object type. * * The result is equivalent for plain object types, but editors show the - * resolved shape (`{ a: 1; b: 2 }`) instead of `A & B`. Optional and readonly - * modifiers are preserved. + * resolved shape (`{ a: 1; b: 2 }`) instead of `A & B`. * * @template T - The type to flatten. * From 245749db8f1562ff171715d6e7ff00620e3b9c9d Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Wed, 8 Jul 2026 22:56:05 +0900 Subject: [PATCH 15/21] test(types): assert SetOptional/SetRequired reject unknown keys --- src/types/SetOptional.spec.ts | 6 ++++++ src/types/SetRequired.spec.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/types/SetOptional.spec.ts b/src/types/SetOptional.spec.ts index 808aba0f8..bebcf648e 100644 --- a/src/types/SetOptional.spec.ts +++ b/src/types/SetOptional.spec.ts @@ -6,4 +6,10 @@ describe('SetOptional', () => { type User = { id: number; name: string; avatar: string }; expectTypeOf>().toEqualTypeOf<{ id: number; name: string; avatar?: string }>(); }); + + it('rejects keys that do not exist on T', () => { + type User = { id: number; name: string }; + // @ts-expect-error keys must exist on T + expectTypeOf>().toBeObject(); + }); }); diff --git a/src/types/SetRequired.spec.ts b/src/types/SetRequired.spec.ts index 7096cb216..74462c66a 100644 --- a/src/types/SetRequired.spec.ts +++ b/src/types/SetRequired.spec.ts @@ -6,4 +6,10 @@ describe('SetRequired', () => { type User = { id?: number; name?: string }; expectTypeOf>().toEqualTypeOf<{ id: number; name?: string }>(); }); + + it('rejects keys that do not exist on T', () => { + type User = { id?: number; name?: string }; + // @ts-expect-error keys must exist on T + expectTypeOf>().toBeObject(); + }); }); From e3832bbfc11daa5e92f9cf87ac1ee94f81273d9d Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:35:38 +0900 Subject: [PATCH 16/21] test(types): pin SetRequired to modifier removal, not undefined stripping --- src/types/SetRequired.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types/SetRequired.spec.ts b/src/types/SetRequired.spec.ts index 74462c66a..383ec11da 100644 --- a/src/types/SetRequired.spec.ts +++ b/src/types/SetRequired.spec.ts @@ -12,4 +12,8 @@ describe('SetRequired', () => { // @ts-expect-error keys must exist on T expectTypeOf>().toBeObject(); }); + + it('only removes the optional modifier, not undefined in a union', () => { + expectTypeOf>().toEqualTypeOf<{ id: number | undefined }>(); + }); }); From 446211592bae7ddafa14ea5079ed424fe409a0a6 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:36:40 +0900 Subject: [PATCH 17/21] test(types): drop inherited Required semantics test --- src/types/SetRequired.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/types/SetRequired.spec.ts b/src/types/SetRequired.spec.ts index 383ec11da..74462c66a 100644 --- a/src/types/SetRequired.spec.ts +++ b/src/types/SetRequired.spec.ts @@ -12,8 +12,4 @@ describe('SetRequired', () => { // @ts-expect-error keys must exist on T expectTypeOf>().toBeObject(); }); - - it('only removes the optional modifier, not undefined in a union', () => { - expectTypeOf>().toEqualTypeOf<{ id: number | undefined }>(); - }); }); From c6947216b35795bff2dd37d9ef4e5a170e0a6794 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:43:14 +0900 Subject: [PATCH 18/21] test(types): unify NonEmptyArray assertions on conditional-type style --- src/types/NonEmptyArray.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/NonEmptyArray.spec.ts b/src/types/NonEmptyArray.spec.ts index 26a6b4f94..8858d1915 100644 --- a/src/types/NonEmptyArray.spec.ts +++ b/src/types/NonEmptyArray.spec.ts @@ -3,8 +3,8 @@ import type { NonEmptyArray } from './NonEmptyArray'; describe('NonEmptyArray', () => { it('guarantees at least one element', () => { - const ok: NonEmptyArray = [1]; - expectTypeOf(ok).toEqualTypeOf>(); + type SingleAssignable = [number] extends NonEmptyArray ? true : false; + expectTypeOf().toEqualTypeOf(); type EmptyAssignable = [] extends NonEmptyArray ? true : false; expectTypeOf().toEqualTypeOf(); From 31cbfe03db1918e2f7ee84555903d7a3210bd8da Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:48:03 +0900 Subject: [PATCH 19/21] fix(types): leave Map keys unchanged in DeepPartial --- src/types/DeepPartial.spec.ts | 6 +++--- src/types/DeepPartial.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index 944e1ed7a..05ba69838 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -26,9 +26,9 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); - it('recurses into Map and Set contents', () => { - expectTypeOf>>().toEqualTypeOf< - Map + it('recurses into Map values and Set contents, leaving Map keys unchanged', () => { + expectTypeOf>>().toEqualTypeOf< + Map<{ id: number }, { a?: number; b?: string }> >(); expectTypeOf>>().toEqualTypeOf>(); }); diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index 765521f4f..c8589d7bc 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -3,8 +3,8 @@ * `Partial` which only affects the first level. * * Recurses into plain objects, arrays/tuples (elements do not become sparse), - * and `Map`/`Set` contents. Functions, `Date`, and `RegExp` pass through - * unchanged. + * `Map` values, and `Set` contents. `Map` keys (lookup identity), functions, + * `Date`, and `RegExp` pass through unchanged. * * @template T - The type to make deeply optional. * @@ -18,8 +18,8 @@ // prettier-ignore export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp ? T : - T extends Map ? Map, DeepPartial> : - T extends ReadonlyMap ? ReadonlyMap, DeepPartial> : + T extends Map ? Map> : + T extends ReadonlyMap ? ReadonlyMap> : T extends Set ? Set> : T extends ReadonlySet ? ReadonlySet> : T extends readonly unknown[] ? { [K in keyof T]: DeepPartial } : From 6507ca14d8cd0c8b5b731646ac57b7530b1ad08e Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:49:29 +0900 Subject: [PATCH 20/21] fix(types): restore Map key recursion in DeepPartial --- src/types/DeepPartial.spec.ts | 4 ++-- src/types/DeepPartial.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index 05ba69838..be7894aca 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -26,9 +26,9 @@ describe('DeepPartial', () => { expectTypeOf>().toEqualTypeOf(); }); - it('recurses into Map values and Set contents, leaving Map keys unchanged', () => { + it('recurses into Map keys and values and Set contents', () => { expectTypeOf>>().toEqualTypeOf< - Map<{ id: number }, { a?: number; b?: string }> + Map<{ id?: number }, { a?: number; b?: string }> >(); expectTypeOf>>().toEqualTypeOf>(); }); diff --git a/src/types/DeepPartial.ts b/src/types/DeepPartial.ts index c8589d7bc..765521f4f 100644 --- a/src/types/DeepPartial.ts +++ b/src/types/DeepPartial.ts @@ -3,8 +3,8 @@ * `Partial` which only affects the first level. * * Recurses into plain objects, arrays/tuples (elements do not become sparse), - * `Map` values, and `Set` contents. `Map` keys (lookup identity), functions, - * `Date`, and `RegExp` pass through unchanged. + * and `Map`/`Set` contents. Functions, `Date`, and `RegExp` pass through + * unchanged. * * @template T - The type to make deeply optional. * @@ -18,8 +18,8 @@ // prettier-ignore export type DeepPartial = T extends ((...args: any[]) => unknown) | Date | RegExp ? T : - T extends Map ? Map> : - T extends ReadonlyMap ? ReadonlyMap> : + T extends Map ? Map, DeepPartial> : + T extends ReadonlyMap ? ReadonlyMap, DeepPartial> : T extends Set ? Set> : T extends ReadonlySet ? ReadonlySet> : T extends readonly unknown[] ? { [K in keyof T]: DeepPartial } : From 51ee20ef0f9ef5ff18beeefc934ac590c0c8614e Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 9 Jul 2026 10:50:02 +0900 Subject: [PATCH 21/21] test(types): cover readonly array and collection preservation in DeepPartial --- src/types/DeepPartial.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts index be7894aca..2d410c80b 100644 --- a/src/types/DeepPartial.spec.ts +++ b/src/types/DeepPartial.spec.ts @@ -33,6 +33,14 @@ describe('DeepPartial', () => { expectTypeOf>>().toEqualTypeOf>(); }); + it('keeps readonly arrays and readonly collections readonly', () => { + expectTypeOf>>().toEqualTypeOf>(); + expectTypeOf>>().toEqualTypeOf< + ReadonlyMap + >(); + expectTypeOf>>().toEqualTypeOf>(); + }); + it('makes Record values partial', () => { type T = Record; expectTypeOf>().toEqualTypeOf>>();