Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f72d51d
feat(types): add es-toolkit/types module with compile-time type utili…
dayongkr Jun 28, 2026
b8d7033
fix(types): add root types.d.ts shim for node10 module resolution
dayongkr Jul 5, 2026
02a6651
feat(types): add DeepPartial
dayongkr Jul 5, 2026
7375a07
refactor(types): simplify DeepPartial to essential branches
dayongkr Jul 5, 2026
90d3e4b
feat(types): add DeepReadonly
dayongkr Jul 5, 2026
dcf0bf1
feat(types): recurse into Map and Set in deep utilities
dayongkr Jul 5, 2026
8b2e3ad
refactor(types): align DeepPartial recursion boundary with merge
dayongkr Jul 5, 2026
af239cb
feat(types): recurse into Map and Set contents in DeepPartial
dayongkr Jul 5, 2026
591a46c
test(types): trim specs to intended design decisions
dayongkr Jul 5, 2026
bc25b34
docs(types): tighten JSDoc to essentials
dayongkr Jul 8, 2026
b53a7df
test(types): drop assertions implied by toEqualTypeOf equality
dayongkr Jul 8, 2026
2d505a6
docs(types): tighten JSDoc on remaining type utilities
dayongkr Jul 8, 2026
76c883c
test(types): cover Record value extraction in ValueOf
dayongkr Jul 8, 2026
a50f2b7
docs(types): drop modifier-preservation note from Simplify
dayongkr Jul 8, 2026
245749d
test(types): assert SetOptional/SetRequired reject unknown keys
dayongkr Jul 8, 2026
e3832bb
test(types): pin SetRequired to modifier removal, not undefined strip…
dayongkr Jul 9, 2026
4462115
test(types): drop inherited Required semantics test
dayongkr Jul 9, 2026
c694721
test(types): unify NonEmptyArray assertions on conditional-type style
dayongkr Jul 9, 2026
31cbfe0
fix(types): leave Map keys unchanged in DeepPartial
dayongkr Jul 9, 2026
6507ca1
fix(types): restore Map key recursion in DeepPartial
dayongkr Jul 9, 2026
51ee20e
test(types): cover readonly array and collection preservation in Deep…
dayongkr Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .scripts/postbuild.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ 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

# 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

Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions src/types/DeepPartial.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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<DeepPartial<Config>>().toEqualTypeOf<{
server?: { host?: string; port?: number };
debug?: boolean;
}>();
});

it('recurses into array elements without making them sparse', () => {
type T = { users: Array<{ name: string; age: number }> };
expectTypeOf<DeepPartial<T>>().toEqualTypeOf<{ users?: Array<{ name?: string; age?: number }> }>();
});

it('preserves tuple shape and arity', () => {
type T = { pair: [number, { x: string }] };
expectTypeOf<DeepPartial<T>>().toEqualTypeOf<{ pair?: [number, { x?: string }] }>();
});

it('keeps functions, Date, and RegExp unchanged', () => {
expectTypeOf<DeepPartial<() => void>>().toEqualTypeOf<() => void>();
expectTypeOf<DeepPartial<Date>>().toEqualTypeOf<Date>();
expectTypeOf<DeepPartial<RegExp>>().toEqualTypeOf<RegExp>();
});

it('recurses into Map keys and values and Set contents', () => {
expectTypeOf<DeepPartial<Map<{ id: number }, { a: number; b: string }>>>().toEqualTypeOf<
Map<{ id?: number }, { a?: number; b?: string }>
>();
expectTypeOf<DeepPartial<Set<{ a: number }>>>().toEqualTypeOf<Set<{ a?: number }>>();
});

it('keeps readonly arrays and readonly collections readonly', () => {
expectTypeOf<DeepPartial<ReadonlyArray<{ a: number }>>>().toEqualTypeOf<ReadonlyArray<{ a?: number }>>();
expectTypeOf<DeepPartial<ReadonlyMap<string, { a: number }>>>().toEqualTypeOf<
ReadonlyMap<string, { a?: number }>
>();
expectTypeOf<DeepPartial<ReadonlySet<{ a: number }>>>().toEqualTypeOf<ReadonlySet<{ a?: number }>>();
});

it('makes Record values partial', () => {
type T = Record<string, { a: number; b: string }>;
expectTypeOf<DeepPartial<T>>().toEqualTypeOf<Partial<Record<string, { a?: number; b?: string }>>>();
});
});
27 changes: 27 additions & 0 deletions src/types/DeepPartial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Makes all properties of `T` optional recursively, unlike the built-in
* `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.
*
* @template T - The type to make deeply optional.
*
* @example
* type Config = { server: { host: string; port: number }; debug: boolean };
* type ConfigPatch = DeepPartial<Config>;
* // => { server?: { host?: string; port?: number }; debug?: boolean }
*
* const patch: ConfigPatch = { server: { port: 8080 } }; // ok, host omitted
*/
// prettier-ignore
export type DeepPartial<T> =
T extends ((...args: any[]) => unknown) | Date | RegExp ? T :
T extends Map<infer K, infer V> ? Map<DeepPartial<K>, DeepPartial<V>> :
T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<DeepPartial<K>, DeepPartial<V>> :
T extends Set<infer U> ? Set<DeepPartial<U>> :
T extends ReadonlySet<infer U> ? ReadonlySet<DeepPartial<U>> :
T extends readonly unknown[] ? { [K in keyof T]: DeepPartial<T[K]> } :
T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
T;
43 changes: 43 additions & 0 deletions src/types/DeepReadonly.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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<DeepReadonly<State>>().toEqualTypeOf<{
readonly user: { readonly name: string; readonly active: boolean };
}>();
});

it('makes arrays readonly and recurses into elements', () => {
type T = { users: Array<{ name: string }> };
expectTypeOf<DeepReadonly<T>>().toEqualTypeOf<{
readonly users: ReadonlyArray<{ readonly name: string }>;
}>();
});

it('preserves tuple shape', () => {
type T = { pair: [number, { x: string }] };
expectTypeOf<DeepReadonly<T>>().toEqualTypeOf<{
readonly pair: readonly [number, { readonly x: string }];
}>();
});

it('keeps functions, Date, and RegExp unchanged', () => {
expectTypeOf<DeepReadonly<() => void>>().toEqualTypeOf<() => void>();
expectTypeOf<DeepReadonly<Date>>().toEqualTypeOf<Date>();
expectTypeOf<DeepReadonly<RegExp>>().toEqualTypeOf<RegExp>();
});

it('converts Map and Set to their readonly counterparts', () => {
expectTypeOf<DeepReadonly<Map<string, { a: number }>>>().toEqualTypeOf<
ReadonlyMap<string, { readonly a: number }>
>();
expectTypeOf<DeepReadonly<Set<{ a: number }>>>().toEqualTypeOf<ReadonlySet<{ readonly a: number }>>();
});

it('makes Record values readonly', () => {
type T = Record<string, { a: number }>;
expectTypeOf<DeepReadonly<T>>().toEqualTypeOf<Readonly<Record<string, { readonly a: number }>>>();
});
});
25 changes: 25 additions & 0 deletions src/types/DeepReadonly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Makes all properties of `T` readonly recursively, unlike the built-in
* `Readonly` which only affects the first level.
*
* 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.
*
* @example
* type State = { user: { name: string; tags: string[] } };
* type FrozenState = DeepReadonly<State>;
* // => { readonly user: { readonly name: string; readonly tags: readonly string[] } }
*
* declare const state: FrozenState;
* state.user.name = 'x'; // error: name is readonly
*/
// prettier-ignore
export type DeepReadonly<T> =
T extends ((...args: any[]) => unknown) | Date | RegExp ? T :
T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> :
T extends ReadonlySet<infer U> ? ReadonlySet<DeepReadonly<U>> :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T;
10 changes: 10 additions & 0 deletions src/types/Merge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { Merge } from './Merge';

describe('Merge', () => {
it('merges two object types, with the second overriding overlapping keys', () => {
type Base = { id: number; createdAt: string };
type Override = { createdAt: Date; extra: boolean };
expectTypeOf<Merge<Base, Override>>().toEqualTypeOf<{ id: number; createdAt: Date; extra: boolean }>();
});
});
16 changes: 16 additions & 0 deletions src/types/Merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Simplify } from './Simplify.ts';

/**
* Merges two object types, with the second type (`U`) overriding the first (`T`).
* 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.
*
* @example
* type Base = { id: number; createdAt: string };
* type Override = { createdAt: Date };
* type Result = Merge<Base, Override>;
* // => { id: number; createdAt: Date }
*/
export type Merge<T, U> = Simplify<Omit<T, keyof U> & U>;
16 changes: 16 additions & 0 deletions src/types/NonEmptyArray.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { NonEmptyArray } from './NonEmptyArray';

describe('NonEmptyArray', () => {
it('guarantees at least one element', () => {
type SingleAssignable = [number] extends NonEmptyArray<number> ? true : false;
expectTypeOf<SingleAssignable>().toEqualTypeOf<true>();

type EmptyAssignable = [] extends NonEmptyArray<number> ? true : false;
expectTypeOf<EmptyAssignable>().toEqualTypeOf<false>();
});

it('resolves the first element to T, not T | undefined', () => {
expectTypeOf<NonEmptyArray<string>[0]>().toEqualTypeOf<string>();
});
});
11 changes: 11 additions & 0 deletions src/types/NonEmptyArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* An array guaranteed to have at least one element.
* The first element resolves to `T` instead of `T | undefined`.
*
* @template T - The type of the elements.
*
* @example
* const a: NonEmptyArray<number> = [1, 2, 3]; // ok
* const b: NonEmptyArray<number> = []; // error: empty array not allowed
*/
export type NonEmptyArray<T> = [T, ...T[]];
15 changes: 15 additions & 0 deletions src/types/SetOptional.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { SetOptional } from './SetOptional';

describe('SetOptional', () => {
it('makes only the given keys optional', () => {
type User = { id: number; name: string; avatar: string };
expectTypeOf<SetOptional<User, 'avatar'>>().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<SetOptional<User, 'nickname'>>().toBeObject();
});
});
15 changes: 15 additions & 0 deletions src/types/SetOptional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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.
*
* @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<User, 'avatar'>;
* // => { id: number; name: string; avatar?: string }
*/
export type SetOptional<T, K extends keyof T> = Simplify<Omit<T, K> & Partial<Pick<T, K>>>;
15 changes: 15 additions & 0 deletions src/types/SetRequired.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { SetRequired } from './SetRequired';

describe('SetRequired', () => {
it('makes only the given keys required', () => {
type User = { id?: number; name?: string };
expectTypeOf<SetRequired<User, 'id'>>().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<SetRequired<User, 'nickname'>>().toBeObject();
});
});
15 changes: 15 additions & 0 deletions src/types/SetRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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.
*
* @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<User, 'avatar'>;
* // => { id: number; name: string; avatar: string }
*/
export type SetRequired<T, K extends keyof T> = Simplify<Omit<T, K> & Required<Pick<T, K>>>;
10 changes: 10 additions & 0 deletions src/types/Simplify.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { Simplify } from './Simplify';

describe('Simplify', () => {
it('flattens an intersection while preserving optional and readonly modifiers', () => {
type A = { name: string };
type B = { readonly age?: number };
expectTypeOf<Simplify<A & B>>().toEqualTypeOf<{ name: string; readonly age?: number }>();
});
});
15 changes: 15 additions & 0 deletions src/types/Simplify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* 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`.
*
* @template T - The type to flatten.
*
* @example
* type A = { name: string };
* type B = { age: number };
* type User = Simplify<A & B>;
* // hover => { name: string; age: number } (instead of A & B)
*/
export type Simplify<T> = { [K in keyof T]: T[K] } & {};
15 changes: 15 additions & 0 deletions src/types/ValueOf.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { ValueOf } from './ValueOf';

describe('ValueOf', () => {
it('creates a union of all value types', () => {
expectTypeOf<ValueOf<{ id: number; name: string }>>().toEqualTypeOf<number | string>();

type Status = { readonly IDLE: 'idle'; readonly LOADING: 'loading'; readonly ERROR: 'error' };
expectTypeOf<ValueOf<Status>>().toEqualTypeOf<'idle' | 'loading' | 'error'>();
});

it('resolves a Record to its value type', () => {
expectTypeOf<ValueOf<Record<string, number>>>().toEqualTypeOf<number>();
});
});
11 changes: 11 additions & 0 deletions src/types/ValueOf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Creates a union of all value types in `T`. The value-side counterpart to `keyof`.
*
* @template T - The object type to read values from.
*
* @example
* const STATUS = { IDLE: 'idle', ERROR: 'error' } as const;
* type Status = ValueOf<typeof STATUS>;
* // => 'idle' | 'error'
*/
export type ValueOf<T> = T[keyof T];
9 changes: 9 additions & 0 deletions src/types/Writable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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<Writable<Config>>().toEqualTypeOf<{ host: string; port: number }>();
});
});
12 changes: 12 additions & 0 deletions src/types/Writable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Removes the `readonly` modifier from all properties of `T`.
* The inverse of the built-in `Readonly`.
*
* @template T - The object type to make writable.
*
* @example
* type Config = { readonly host: string; readonly port: number };
* type MutableConfig = Writable<Config>;
* // => { host: string; port: number }
*/
export type Writable<T> = { -readonly [K in keyof T]: T[K] };
9 changes: 9 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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';
export type { SetRequired } from './SetRequired.ts';
export type { Simplify } from './Simplify.ts';
export type { ValueOf } from './ValueOf.ts';
export type { Writable } from './Writable.ts';
Loading