Skip to content

setPath

Edit

Immutably sets a value using dot notation or explicit path segments.

setPath.ts
/** A dot-delimited string or an explicit list of property keys. */
export type ObjectPath = string | readonly PropertyKey[];
type KnownNonContainer =
| Date
| RegExp
| Error
| Promise<unknown>
| ReadonlyMap<unknown, unknown>
| ReadonlySet<unknown>
| WeakMap<object, unknown>
| WeakSet<object>
| ArrayBuffer
| SharedArrayBuffer
| ArrayBufferView
| ((...arguments_: never[]) => unknown)
| (abstract new (...arguments_: never[]) => unknown);
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type NonZeroDigit = Exclude<Digit, "0">;
type IsDigits<Text extends string> = Text extends ""
? true
: Text extends `${Digit}${infer Rest}`
? IsDigits<Rest>
: false;
type IsCanonicalArrayIndexText<Text extends string> = Text extends "0"
? true
: Text extends `${NonZeroDigit}${infer Rest}`
? IsDigits<Rest>
: false;
type DigitRank<Value extends Digit> = Value extends "0"
? []
: Value extends "1"
? [0]
: Value extends "2"
? [0, 0]
: Value extends "3"
? [0, 0, 0]
: Value extends "4"
? [0, 0, 0, 0]
: Value extends "5"
? [0, 0, 0, 0, 0]
: Value extends "6"
? [0, 0, 0, 0, 0, 0]
: Value extends "7"
? [0, 0, 0, 0, 0, 0, 0]
: Value extends "8"
? [0, 0, 0, 0, 0, 0, 0, 0]
: [0, 0, 0, 0, 0, 0, 0, 0, 0];
type IsDigitLess<Left extends Digit, Right extends Digit> =
DigitRank<Right> extends [...DigitRank<Left>, ...infer Rest]
? Rest extends []
? false
: true
: false;
type CompareLength<
Left extends string,
Right extends string,
> = Left extends `${Digit}${infer LeftRest}`
? Right extends `${Digit}${infer RightRest}`
? CompareLength<LeftRest, RightRest>
: "greater"
: Right extends `${Digit}${string}`
? "less"
: "equal";
type IsLexicallyAtMost<
Value extends string,
Maximum extends string,
> = Value extends ""
? true
: Value extends `${infer ValueHead extends Digit}${infer ValueTail}`
? Maximum extends `${infer MaximumHead extends Digit}${infer MaximumTail}`
? ValueHead extends MaximumHead
? IsLexicallyAtMost<ValueTail, MaximumTail>
: IsDigitLess<ValueHead, MaximumHead>
: false
: false;
type IsAtMostMaximumArrayIndex<Text extends string> = CompareLength<
Text,
"4294967294"
> extends "less"
? true
: CompareLength<Text, "4294967294"> extends "greater"
? false
: IsLexicallyAtMost<Text, "4294967294">;
type IsArrayIndexText<Text extends string> =
IsCanonicalArrayIndexText<Text> extends true
? IsAtMostMaximumArrayIndex<Text>
: false;
type IsArrayIndexKey<Key> = Key extends number
? number extends Key
? false
: IsArrayIndexText<`${Key}`>
: Key extends string
? IsArrayIndexText<Key>
: false;
type SplitPath<Path extends string> = Path extends ""
? []
: Path extends `${infer Head}.${infer Tail}`
? [Head, ...SplitPath<Tail>]
: [Path];
type PathSegments<Path extends ObjectPath> = Path extends string
? SplitPath<Path>
: Path;
type IsUnion<Value, Whole = Value> = Value extends unknown
? [Whole] extends [Value]
? false
: true
: never;
type IsSingletonPropertyKey<Value extends PropertyKey> =
[Value] extends [never]
? false
: IsUnion<Value> extends true
? false
: {} extends Record<Value, never>
? false
: true;
type IsDynamicSegment<Value extends PropertyKey> =
IsSingletonPropertyKey<Value> extends true ? false : true;
type HasDynamicSegment<Segments extends readonly PropertyKey[]> =
Segments extends readonly [
infer Head extends PropertyKey,
...infer Tail extends PropertyKey[],
]
? IsDynamicSegment<Head> extends true
? true
: HasDynamicSegment<Tail>
: false;
type IsDynamicPath<Path extends ObjectPath> =
IsUnion<Path> extends true
? true
: [Path] extends [string]
? IsDynamicSegment<Extract<Path, string>>
: [Path] extends [readonly PropertyKey[]]
? number extends Extract<Path, readonly PropertyKey[]>["length"]
? true
: IsUnion<
Extract<Path, readonly PropertyKey[]>["length"]
> extends true
? true
: HasDynamicSegment<Extract<Path, readonly PropertyKey[]>>
: true;
type Simplify<T> = { [Key in keyof T]: T[Key] };
type IsAny<T> = 0 extends 1 & T ? true : false;
declare const missingPathValue: unique symbol;
type MissingPathValue = typeof missingPathValue;
type MatchingObjectKey<T extends object, Key> = Key extends keyof T
? Key
: Key extends string
? Key extends `${infer NumberKey extends number}`
? `${NumberKey}` extends Key
? NumberKey extends keyof T
? NumberKey
: never
: never
: never
: Key extends number
? `${Key}` extends keyof T
? `${Key}`
: never
: never;
type PropertyAtBranch<T, Key> =
NonNullable<T> extends readonly (infer Item)[]
? Key extends number
? number extends Key
? Item | MissingPathValue
: IsArrayIndexKey<Key> extends true
? Item | MissingPathValue
: MissingPathValue
: IsArrayIndexKey<Key> extends true
? Item | MissingPathValue
: MatchingObjectKey<NonNullable<T>, Key> extends infer Matched
? [Matched] extends [never]
? MissingPathValue
: NonNullable<T>[Extract<Matched, keyof NonNullable<T>>]
: MissingPathValue
: NonNullable<T> extends object
? MatchingObjectKey<NonNullable<T>, Key> extends infer Matched
? [Matched] extends [never]
? MissingPathValue
: NonNullable<T>[Extract<Matched, keyof NonNullable<T>>]
: MissingPathValue
: MissingPathValue;
type PropertyAt<T, Key> = T extends unknown
? PropertyAtBranch<T, Key>
: never;
type SetProperty<T, Key extends PropertyKey, Value> = T extends unknown
? T extends MissingPathValue | null | undefined
? IsArrayIndexKey<Key> extends true
? Array<Value>
: { [Property in Key]: Value }
: T extends readonly (infer Item)[]
? IsArrayIndexKey<Key> extends true
? Array<Item | Value>
: Simplify<
Omit<T, Extract<Key, keyof T>> & {
[Property in Key]: Value;
}
>
: T extends object
? Simplify<
Omit<T, Extract<Key, keyof T>> & {
[Property in Key]: Value;
}
>
: IsArrayIndexKey<Key> extends true
? Array<Value>
: { [Property in Key]: Value }
: never;
type SetAtSegments<
T,
Segments extends readonly PropertyKey[],
Value,
> = Segments extends readonly [
infer Head extends PropertyKey,
...infer Tail extends PropertyKey[],
]
? Tail extends []
? SetProperty<T, Head, Value>
: SetProperty<T, Head, SetAtSegments<PropertyAt<T, Head>, Tail, Value>>
: never;
type ResolveSegments<T, Segments extends readonly PropertyKey[]> =
Segments extends readonly [
infer Head extends PropertyKey,
...infer Tail extends PropertyKey[],
]
? ResolveSegments<PropertyAt<T, Head>, Tail>
: T;
type IsDefinitelyKnownNonContainer<Value> = [
IsAny<Value>,
] extends [true]
? false
: [Exclude<Value, MissingPathValue | null | undefined>] extends [never]
? false
: [
Exclude<Value, MissingPathValue | null | undefined>,
] extends [KnownNonContainer]
? true
: false;
type HasKnownNonContainerTraversal<
T,
Segments extends readonly PropertyKey[],
> = Segments extends readonly [
infer Head extends PropertyKey,
...infer Tail extends PropertyKey[],
]
? Tail extends []
? false
: IsDefinitelyKnownNonContainer<PropertyAt<T, Head>> extends true
? true
: HasKnownNonContainerTraversal<PropertyAt<T, Head>, Tail>
: false;
type IsArrayLengthMutation<T, Path extends ObjectPath> =
PathSegments<Path> extends readonly [
...infer Parent extends PropertyKey[],
infer Last extends PropertyKey,
]
? Last extends "length"
? Extract<
Exclude<ResolveSegments<T, Parent>, MissingPathValue>,
readonly unknown[]
> extends never
? false
: true
: false
: false;
/** A non-empty set path that does not target an array's `length`. */
export type ValidSetPath<T, Path extends ObjectPath> =
IsDynamicPath<Path> extends true
? Path
: PathSegments<Path> extends readonly []
? never
: IsArrayLengthMutation<T, Path> extends true
? never
: HasKnownNonContainerTraversal<T, PathSegments<Path>> extends true
? never
: Path;
/** A root that is not a statically known non-plain built-in or function. */
export type ValidMutationRoot<T extends object> =
IsAny<T> extends true ? T : T extends KnownNonContainer ? never : T;
/** The immutable result of assigning `Value` at a literal path. */
export type SetPathResult<
T extends object,
Path extends ObjectPath,
Value,
> = IsDynamicPath<Path> extends true
? object
: SetAtSegments<T, PathSegments<Path>, Value>;
const toSegments = (path: ObjectPath): PropertyKey[] => {
const segments =
typeof path === "string" ? (path === "" ? [] : path.split(".")) : [...path];
if (segments.length === 0) {
throw new RangeError("path must contain at least one segment");
}
return segments;
};
const isArrayIndex = (value: PropertyKey): boolean => {
if (typeof value === "symbol") return false;
const number = typeof value === "number" ? value : Number(value);
if (
typeof value === "string" &&
!/^(0|[1-9]\d*)$/.test(value)
) {
return false;
}
return (
Number.isSafeInteger(number) &&
number >= 0 &&
number < 4_294_967_295
);
};
const isContainer = (
value: unknown
): value is Record<PropertyKey, unknown> => {
if (value === null || typeof value !== "object") return false;
if (Array.isArray(value)) return true;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const defineDataProperty = (
object: Record<PropertyKey, unknown>,
key: PropertyKey,
value: unknown
): void => {
if (Array.isArray(object) && key === "length") {
Object.defineProperty(object, key, { value });
return;
}
Object.defineProperty(object, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
};
const cloneContainer = (
value: Record<PropertyKey, unknown>,
omittedKey: PropertyKey
): Record<PropertyKey, unknown> => {
const clone = Array.isArray(value)
? new Array(value.length)
: Object.create(Object.getPrototypeOf(value));
for (const key of Reflect.ownKeys(value)) {
if (key === omittedKey || (Array.isArray(value) && key === "length")) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor !== undefined) Object.defineProperty(clone, key, descriptor);
}
if (Array.isArray(value)) {
Object.defineProperty(
clone,
"length",
Object.getOwnPropertyDescriptor(value, "length")!
);
}
return clone;
};
const defineReplacementProperty = (
object: Record<PropertyKey, unknown>,
source: unknown,
key: PropertyKey,
value: unknown
): void => {
const descriptor = isContainer(source)
? Object.getOwnPropertyDescriptor(source, key)
: undefined;
if (descriptor === undefined) {
defineDataProperty(object, key, value);
return;
}
Object.defineProperty(object, key, {
value,
enumerable: descriptor.enumerable ?? false,
configurable: descriptor.configurable ?? false,
writable: "writable" in descriptor ? descriptor.writable : true,
});
};
const readOwn = (value: unknown, key: PropertyKey): unknown =>
isContainer(value) && Object.prototype.hasOwnProperty.call(value, key)
? Reflect.get(value, key)
: undefined;
/** Immutably sets a value at a dot-delimited or segment-based object path. */
export const setPath = <
T extends object,
const Path extends ObjectPath,
Value,
>(
object: T & ValidMutationRoot<T>,
path: Path & ValidSetPath<T, Path>,
value: Value
): SetPathResult<T, Path, Value> => {
if (!isContainer(object)) {
throw new TypeError("object must be a plain object or array");
}
const segments = toSegments(path);
const root = cloneContainer(object, segments[0]!);
let source: unknown = object;
let target = root;
segments.forEach((segment, index) => {
if (index === segments.length - 1) {
if (Array.isArray(target) && segment === "length") {
throw new TypeError("path cannot mutate an array's length");
}
defineReplacementProperty(target, source, segment, value);
return;
}
const existing = readOwn(source, segment);
if (
existing !== null &&
(typeof existing === "object" || typeof existing === "function") &&
!isContainer(existing)
) {
throw new TypeError("path cannot traverse a non-plain object");
}
const next = isContainer(existing)
? cloneContainer(existing, segments[index + 1]!)
: isArrayIndex(segments[index + 1]!)
? []
: {};
defineReplacementProperty(target, source, segment, next);
source = existing;
target = next as unknown as Record<PropertyKey, unknown>;
});
return root as SetPathResult<T, Path, Value>;
};
Terminal
wget -O src/lib/setPath.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/functions/setPath.ts
setPath.example.ts
import { setPath } from "./setPath";
const next = setPath(
{ user: { name: "Ada" } },
"user.active",
true
);
  • object (T) — Plain-object or array root.
  • path (Path) — Non-empty dot path or explicit segment tuple.
  • value (Value) — Replacement value. Missing containers are created.

SetPathResult<T, Path, Value> — An immutable, path-aware result for literal paths. Replaced properties use Value instead of retaining their old type. Widened dynamic paths return the conservative object type.

Broad, union, branded, and template-pattern segments, plus unions of tuple shapes, are dynamic paths and return object, because no individual destination key is guaranteed.

Only canonical JavaScript array indexes from 0 through 4294967294 create arrays. Larger, negative, fractional, NaN, and infinite numbers are ordinary object keys. Empty paths and paths targeting an array’s non-configurable length property are rejected. Plain objects and arrays are copied along the changed path; a non-plain root or intermediate object throws TypeError.

Known built-ins and functions are rejected statically as roots and as literal intermediate segments. A built-in may still be replaced at the final segment. Interfaces and arbitrary custom classes cannot be distinguished from plain objects structurally, so they retain the runtime check.

Untouched own properties—including non-enumerable, symbol, accessor, and special properties—retain their descriptors in the copied containers.

Every write creates an own data property, so __proto__, constructor, and prototype are supported without prototype mutation.