pathsafe
v2.1.0
Published
Type-safe nested object access for TypeScript — typed dot-paths with validation by any Standard Schema library (Zod, Valibot, ArkType…)
Maintainers
Readme
pathsafe
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
Quick start
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.
Validate at a path — with your validator
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.
Validate several paths at once
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
}Constrain paths by value type
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 stringWildcard paths
getMany 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.
Immutable mode
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 }).
Security
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.
Benchmarks
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.
Standalone functions
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 runtimeAPI reference
safePath(obj, defaultOptions?)
| 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).
Exported types
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)
Behavior notes
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
.).
License
MIT
