diff --git a/.scripts/postbuild.sh b/.scripts/postbuild.sh index ab67deab4..dc8e49bf8 100755 --- a/.scripts/postbuild.sh +++ b/.scripts/postbuild.sh @@ -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 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/DeepPartial.spec.ts b/src/types/DeepPartial.spec.ts new file mode 100644 index 000000000..ab52be2ed --- /dev/null +++ b/src/types/DeepPartial.spec.ts @@ -0,0 +1,68 @@ +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 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 new file mode 100644 index 000000000..8b98e4044 --- /dev/null +++ b/src/types/DeepPartial.ts @@ -0,0 +1,29 @@ +/** + * 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. + * + * @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 + */ +// 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 new file mode 100644 index 000000000..705a89f76 --- /dev/null +++ b/src/types/DeepReadonly.spec.ts @@ -0,0 +1,58 @@ +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('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 new file mode 100644 index 000000000..a88c77eef --- /dev/null +++ b/src/types/DeepReadonly.ts @@ -0,0 +1,26 @@ +/** + * 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. + * + * @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 + */ +// 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; diff --git a/src/types/Merge.spec.ts b/src/types/Merge.spec.ts new file mode 100644 index 000000000..9d1acb291 --- /dev/null +++ b/src/types/Merge.spec.ts @@ -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>().toEqualTypeOf<{ id: number; createdAt: Date; extra: boolean }>(); + }); +}); 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..26a6b4f94 --- /dev/null +++ b/src/types/NonEmptyArray.spec.ts @@ -0,0 +1,16 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { NonEmptyArray } from './NonEmptyArray'; + +describe('NonEmptyArray', () => { + 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(); + }); +}); 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..808aba0f8 --- /dev/null +++ b/src/types/SetOptional.spec.ts @@ -0,0 +1,9 @@ +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>().toEqualTypeOf<{ id: number; name: string; avatar?: 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..7096cb216 --- /dev/null +++ b/src/types/SetRequired.spec.ts @@ -0,0 +1,9 @@ +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>().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..ee69cf3f1 --- /dev/null +++ b/src/types/Simplify.spec.ts @@ -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>().toEqualTypeOf<{ name: string; readonly age?: number }>(); + }); +}); 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..5c94cd487 --- /dev/null +++ b/src/types/ValueOf.spec.ts @@ -0,0 +1,11 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ValueOf } from './ValueOf'; + +describe('ValueOf', () => { + it('creates a union of all value types', () => { + expectTypeOf>().toEqualTypeOf(); + + 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..a3dc6d76d --- /dev/null +++ b/src/types/Writable.spec.ts @@ -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>().toEqualTypeOf<{ host: string; port: number }>(); + }); +}); 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..9dcc28c5b --- /dev/null +++ b/src/types/index.ts @@ -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';