Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 68 additions & 0 deletions src/types/DeepPartial.spec.ts
Original file line number Diff line number Diff line change
@@ -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<DeepPartial<Config>>().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<Config> = { server: { port: 8080 } };
expectTypeOf(ok).toEqualTypeOf<DeepPartial<Config>>();

// optionality must not weaken value types
// @ts-expect-error port must still be a number
const bad: DeepPartial<Config> = { server: { port: '8080' } };
expectTypeOf(bad).toEqualTypeOf<DeepPartial<Config>>();
});

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 }> }>();

// incomplete elements are allowed, holes are not
const ok: DeepPartial<T> = { users: [{ name: 'kim' }] };
expectTypeOf(ok).toEqualTypeOf<DeepPartial<T>>();

// @ts-expect-error sparse arrays stay illegal
const bad: DeepPartial<T> = { users: [undefined] };
expectTypeOf(bad).toEqualTypeOf<DeepPartial<T>>();
});

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

// @ts-expect-error tuple elements do not become optional
const bad: DeepPartial<T> = { pair: [1] };
expectTypeOf(bad).toEqualTypeOf<DeepPartial<T>>();
});

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 and Set contents', () => {
expectTypeOf<DeepPartial<Map<string, { a: number; b: string }>>>().toEqualTypeOf<
Map<string, { a?: number; b?: string }>
>();
expectTypeOf<DeepPartial<Set<{ a: number }>>>().toEqualTypeOf<Set<{ a?: number }>>();

type Config = { routes: Map<string, { handler: string }> };
// @ts-expect-error a plain object must not pass as a Map
const bad: DeepPartial<Config> = { routes: {} };
expectTypeOf(bad).toEqualTypeOf<DeepPartial<Config>>();
});

it('makes Record values partial', () => {
type T = Record<string, { a: number; b: string }>;
expectTypeOf<DeepPartial<T>>().toEqualTypeOf<Partial<Record<string, { a?: number; b?: string }>>>();
});
});
29 changes: 29 additions & 0 deletions src/types/DeepPartial.ts
Original file line number Diff line number Diff line change
@@ -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<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 :

Check warning on line 22 in src/types/DeepPartial.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 22 in src/types/DeepPartial.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
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;
58 changes: 58 additions & 0 deletions src/types/DeepReadonly.spec.ts
Original file line number Diff line number Diff line change
@@ -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<DeepReadonly<State>>().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<State>;

// @ts-expect-error nested properties are readonly
state.user.name = 'lee';
expectTypeOf(state).toEqualTypeOf<DeepReadonly<State>>();
});

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 }>>();

type State = { cache: Map<string, number> };
const state = { cache: new Map<string, number>() } as DeepReadonly<State>;
// @ts-expect-error mutation methods disappear on ReadonlyMap
state.cache.set('k', 1);
expectTypeOf(state).toEqualTypeOf<DeepReadonly<State>>();
});

it('makes Record values readonly', () => {
type T = Record<string, { a: number }>;
expectTypeOf<DeepReadonly<T>>().toEqualTypeOf<Readonly<Record<string, { readonly a: number }>>>();
});
});
26 changes: 26 additions & 0 deletions src/types/DeepReadonly.ts
Original file line number Diff line number Diff line change
@@ -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<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 :

Check warning on line 22 in src/types/DeepReadonly.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

Check warning on line 22 in src/types/DeepReadonly.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
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 }>();
});
});
19 changes: 19 additions & 0 deletions src/types/Merge.ts
Original file line number Diff line number Diff line change
@@ -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<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', () => {
const ok: NonEmptyArray<number> = [1];
expectTypeOf(ok).toEqualTypeOf<NonEmptyArray<number>>();

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>();
});
});
20 changes: 20 additions & 0 deletions src/types/NonEmptyArray.ts
Original file line number Diff line number Diff line change
@@ -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<number> = [1]; // ok
* const b: NonEmptyArray<number> = [1, 2, 3]; // ok
* const c: NonEmptyArray<number> = []; // error: empty array not allowed
*
* @example
* function first<T>(arr: NonEmptyArray<T>): T {
* return arr[0]; // T, not T | undefined
* }
*/
export type NonEmptyArray<T> = [T, ...T[]];
9 changes: 9 additions & 0 deletions src/types/SetOptional.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SetOptional<User, 'avatar'>>().toEqualTypeOf<{ id: number; name: string; avatar?: string }>();
});
});
17 changes: 17 additions & 0 deletions src/types/SetOptional.ts
Original file line number Diff line number Diff line change
@@ -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<User, 'avatar'>;
* // => { id: number; name: string; avatar?: string }
*/
export type SetOptional<T, K extends keyof T> = Simplify<Omit<T, K> & Partial<Pick<T, K>>>;
9 changes: 9 additions & 0 deletions src/types/SetRequired.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SetRequired<User, 'id'>>().toEqualTypeOf<{ id: number; name?: string }>();
});
});
17 changes: 17 additions & 0 deletions src/types/SetRequired.ts
Original file line number Diff line number Diff line change
@@ -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<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 }>();
});
});
16 changes: 16 additions & 0 deletions src/types/Simplify.ts
Original file line number Diff line number Diff line change
@@ -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<A & B>;
* // hover => { name: string; age: number } (instead of A & B)
*/
export type Simplify<T> = { [K in keyof T]: T[K] } & {};
11 changes: 11 additions & 0 deletions src/types/ValueOf.spec.ts
Original file line number Diff line number Diff line change
@@ -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<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'>();
});
});
Loading
Loading