Type-safe nested object access for TypeScript — fully typed dot-paths ('user.address.city') with autocompletion, plus validation at any path with the validator you already use: Zod, Valibot, ArkType, or any Standard Schema library.
pnpm add pathsafe # npm install pathsafe / yarn add pathsafe
- 🎯 Full path autocompletion — every valid dot-path of your type, inferred
- 🛡️ Actually safe — hardened against prototype pollution (
__proto__,constructor,prototypeare rejected on write) - ✅ Bring your own validator —
validate/validateAndSetaccept any Standard Schema (Zod 3.24+, Valibot v1+, ArkType 2+, Effect Schema…) - 🧊 Immutable mode — copy-on-write with structural sharing (React-friendly, no deep clone)
- 📦 Zero dependencies — ESM + CJS, ~8 KB unminified
import { safePath } from 'pathsafe';
const data = {
user: {
name: 'John',
profile: {
address: { city: 'Paris', country: 'France' },
},
hobbies: ['coding', 'reading'],
},
};
const sp = safePath(data);
sp.get('user.profile.address.city'); // "Paris" (type: string | undefined)
sp.get('user.hobbies.0'); // "coding" — arrays are typed too
sp.get('user.hobbies.5', 'none'); // typed default when the value is missing
sp.set('user.name', 'Jane'); // value type-checked against the path
sp.has('user.profile.address'); // true
sp.update('user.name', (n) => n?.toUpperCase() ?? 'ANONYMOUS');
sp.delete('user.profile.address.country');
sp.merge({ user: { profile: { address: { city: 'Lyon' } } } });
sp.pick(['user.name', 'user.profile.address.city']);
// { 'user.name': 'Jane', 'user.profile.address.city': 'Lyon' } — fully typedPaths autocomplete as you type, invalid paths are compile errors, and the value type is inferred from the path. Works with interfaces, type aliases, optional properties, arrays, tuples, and unions.
This is the part no other path library does: validate the value at a typed path using any Standard Schema validator. No adapter, no wrapper.
import { safePath } from 'pathsafe';
import { z } from 'zod';
const sp = safePath(data);
// Validate the current value at a path
const result = sp.validate('user.profile.address.city', z.string().min(1));
if (result.issues) {
console.log(result.issues.map((i) => i.message));
} else {
result.value; // typed output
}
// Validate an incoming value, then set it — in one typed step
sp.validateAndSet('user.name', input, z.string().min(2));
// throws PathValidationError on failure (or pass { strict: false } to no-op)
// Schema transforms are applied before setting
sp.validateAndSet('user.name', ' Jane ', z.string().transform((s) => s.trim()));The exact same code works with Valibot, ArkType, or any other Standard Schema library:
import * as v from 'valibot';
sp.validate('user.profile.address.city', v.pipe(v.string(), v.minLength(1)));
sp.validateAndSet('user.name', input, v.pipe(v.string(), v.trim()));Async schemas (e.g. Zod .refine(async …)) are supported via validateAsync and validateAndSetAsync.
validateAll checks each path against its own schema and aggregates the issues by path — perfect for validating a form or a config object field by field, even mixing validator libraries:
const result = sp.validateAll({
'user.name': z.string().min(2),
'user.profile.address.city': v.pipe(v.string(), v.minLength(1)), // Valibot here, why not
});
if (!result.success) {
result.issues; // { 'user.name': [...issues] } — only failing paths
}PathsTo<T, V> is the type of every path leading to a V — ideal for typed translation keys, form field bindings, or any API that only accepts certain value types:
import type { PathsTo } from 'pathsafe';
declare function bindNumericField(path: PathsTo<FormValues, number>): void;
bindNumericField('user.age'); // ✔
bindNumericField('user.name'); // ✘ compile error — leads to a stringgetMany resolves * against every array element or record value — fully typed, autocompleted, and flattened across nested wildcards:
const sp = safePath({
users: [
{ name: 'Alice', tags: ['admin', 'dev'] },
{ name: 'Bob', tags: ['dev'] },
],
settings: { colors: { primary: 'blue', accent: 'coral' } },
});
sp.getMany('users.*.name'); // string[] → ['Alice', 'Bob']
sp.getMany('users.*.tags.*'); // string[] → ['admin', 'dev', 'dev']
sp.getMany('settings.colors.*'); // string[] → ['blue', 'coral']Wildcards only iterate own enumerable values — the prototype chain stays unreachable.
All mutating operations accept { immutable: true } and return a new object using copy-on-write: only the nodes along the path are cloned, everything else keeps its reference — ideal for React state and memoization.
const original = { user: { name: 'John' }, settings: { theme: 'dark' } };
const sp = safePath(original);
const updated = sp.set('user.name', 'Jane', { immutable: true });
// original.user.name === "John"
// updated.user.name === "Jane"
// updated.settings === original.settings (untouched branch, same reference)Works with set, delete, update, merge, and validateAndSet. You can also make it the default: safePath(obj, { immutable: true }).
pathsafe refuses to write through the prototype chain:
sp.set('__proto__.isAdmin', true); // throws TypeError
sp.merge(JSON.parse(maliciousJson)); // __proto__ keys are skippedReads use own-property semantics (Object.hasOwn), so inherited properties like toString are never reachable through a path.
5-level deep path, vitest bench on Node 22 (Apple Silicon) — run pnpm bench yourself:
| Operation | pathsafe | lodash | dot-prop |
|---|---|---|---|
get |
11.9M ops/s | 8.3M ops/s | 2.8M ops/s |
set |
11.2M ops/s | 6.0M ops/s | 2.7M ops/s |
immutable set |
5.3M ops/s | — | — |
Native optional chaining remains ~4× faster than any path library — use it when your paths are static. pathsafe is for the dynamic-path cases, with a bounded LRU cache for parsed paths. Immutable copy-on-write is 6.4× faster than the naive structuredClone + assign approach.
import {
getValueByPath,
setValueByPath,
hasPath,
deletePath,
isValidPath,
getAllPaths,
clearPathCache,
} from 'pathsafe';
getValueByPath(obj, 'user.name');
setValueByPath(obj, 'user.name', 'Jane');
hasPath(obj, 'user.name');
deletePath(obj, 'user.name');
isValidPath(obj, someString); // runtime check, narrows to a typed path
getAllPaths(obj); // every path present at runtime| Method | Returns |
|---|---|
get(path) |
PathValue<T, P> | undefined |
get(path, defaultValue) |
PathValue<T, P> | D |
set(path, value, options?) |
T |
has(path) |
boolean |
delete(path, options?) |
T |
update(path, fn, options?) |
T |
merge(partial, options?) |
T |
pick(paths) |
{ [K in P]: PathValue<T, K> | undefined } |
getMany(wildcardPath) |
WildcardPathValue<T, P>[] |
getAllPaths() |
PathKeys<T>[] |
isValidPath(path) |
path is PathKeys<T> |
validate(path, schema) |
StandardSchemaV1.Result<Output> |
validateAsync(path, schema) |
Promise<StandardSchemaV1.Result<…>> |
validateAll(schemas) |
PathsValidationResult |
validateAndSet(path, value, schema, options?) |
T |
validateAndSetAsync(path, value, schema, options?) |
Promise<T> |
Options: { immutable?: boolean } for mutating methods.
Validated options: also { strict?: boolean } (default true — throws PathValidationError on failure; false returns the object unchanged).
PathKeys<T, Depth = 10>— union of every dot-path inT(bounded recursion; raiseDepthfor very deep types)PathValue<T, P>— the type at pathPPathsTo<T, V>— every path inTwhose value is assignable toVWildcardPathKeys<T>/WildcardPathValue<T, P>— typed*paths forgetManyPathSchemas<T>/PathsValidationResult— input and output ofvalidateAllStandardSchemaV1— the vendored Standard Schema interfacePathValidationError— thrown byvalidateAndSetin strict mode (.path,.issues)
deleteon an array index splices (no holes left behind).setcreates intermediate arrays for numeric segments ('list.0.name'creates{ list: [{ name }] }).- Built-in objects (
Date,RegExp,Map,Set…) are leaves: paths never traverse into them. - Keys containing dots are not addressable (paths split on
.).
MIT