Parse, normalize and safely access JavaScript object paths.
object-path-kit is a small TypeScript utility for applications where object paths are data: JSON tools, table builders, import/export mappings, dynamic forms, admin dashboards and configuration UIs.
It focuses on predictable path parsing and safe access rather than trying to be a large object manipulation toolkit.
- TypeScript types are generated from the source.
- ESM-only package with no runtime dependencies.
- Marked as side-effect free for bundlers.
- CI runs
npm ci,typecheck,build, andtest. - Tested on Node.js 20 and 22 with GitHub Actions.
- Keeps prototype-pollution-sensitive path segments blocked by default.
Reading user.profile.name is easy when the path is hard-coded:
user.profile.name;It becomes less trivial when the path comes from a column definition, a user setting, a CSV mapping, a form schema or a rule engine:
getPath(order, 'customer.address.city');
getPath(event, 'context["user.name"]');
getPath(report, 'rows[0].metrics.revenue');object-path-kit gives you one small, dependency-free layer for those dynamic paths:
- parse path strings into typed segments
- normalize equivalent path syntaxes
- safely read and test own properties
- immutably write nested values
- reject prototype-pollution segments by default
npm install object-path-kitimport {
getPath,
hasPath,
normalizePath,
parsePath,
deletePathImmutable,
setPathImmutable,
stringifyPath
} from 'object-path-kit';
const data = {
users: [
{ profile: { name: 'Ada', active: true } }
],
meta: {
'user.name': 'Grace'
}
};
parsePath('users[0].profile.name');
// ['users', 0, 'profile', 'name']
stringifyPath(['meta', 'user.name']);
// meta["user.name"]
normalizePath("users[0]['profile.name']");
// users[0]["profile.name"]
getPath(data, 'users[0].profile.name');
// Ada
getPath(data, 'missing.value', 'fallback');
// fallback
hasPath(data, 'users[0].profile.active');
// true
const next = setPathImmutable(data, 'users[0].profile.name', 'Grace');
data.users[0].profile.name;
// Ada
getPath(next, 'users[0].profile.name');
// Grace
const withoutActive = deletePathImmutable(next, 'users[0].profile.active');Use paths as column definitions without writing per-column accessors.
const columns = [
{ header: 'Customer', path: 'customer.name' },
{ header: 'City', path: 'customer.address.city' },
{ header: 'Total', path: 'totals[0].amount' }
];
const row = columns.map((column) => getPath(order, column.path, ''));Pair with object-key-paths when paths should be discovered from an unknown payload before values are read:
import { getLeafPaths } from 'object-key-paths';
import { getPath } from 'object-path-kit';
const paths = getLeafPaths(report, {
pathStyle: 'bracket'
});
const fields = paths.map((path) => ({
path,
value: getPath(report, path)
}));Use the same idea with array-table-kit when paths come from user settings or config. For bracket notation or keys containing dots, use an accessor:
import { getPath } from 'object-path-kit';
import { arrayToMarkdownTable } from 'array-table-kit';
const cityPath = 'customer["billing.address"].city';
arrayToMarkdownTable(orders, {
columns: [
{ key: 'customer', path: 'customer.name' },
{
key: 'city',
header: 'City',
accessor: (row) => getPath(row, cityPath, '') as string
}
]
});Map incoming fields to nested output objects.
const mapping = {
'Customer name': 'customer.name',
'Billing ZIP': 'billing.address.zipCode'
};
const output = Object.entries(mapping).reduce(
(record, [csvHeader, path]) => setPathImmutable(record, path, csvRow[csvHeader]),
{}
);Use json-csv-kit when those mappings also need a CSV export. accessor keeps bracket paths and ambiguous keys under your control:
import { getPath } from 'object-path-kit';
import { jsonToCsv } from 'json-csv-kit';
const columns = [
{ key: 'customer', header: 'Customer', path: 'customer.name' },
{ key: 'city', header: 'City', path: 'customer["billing.address"].city' }
];
const csv = jsonToCsv(orders, {
columns: columns.map((column) => ({
key: column.key,
header: column.header,
accessor: (row) => getPath(row, column.path, '')
}))
});Bind fields to nested state without mutating the original object.
const field = {
label: 'Email',
path: 'user.profile.email'
};
const value = getPath(formState, field.path, '');
const nextState = setPathImmutable(formState, field.path, 'ada@example.com');
const clearedState = deletePathImmutable(nextState, field.path);Validate paths once, then reuse their parsed segments.
const result = validatePath(rule.path);
if (!result.valid) {
console.error(result.error.message);
}Dot notation:
parsePath('user.profile.name');
// ['user', 'profile', 'name']Array indexes:
parsePath('items[0].price');
// ['items', 0, 'price']Quoted keys:
parsePath('metadata["user.name"]');
// ['metadata', 'user.name']
parsePath("metadata['display-name']");
// ['metadata', 'display-name']Escaped bare segments:
parsePath('metadata.user\\.name');
// ['metadata', 'user.name']Empty path:
parsePath('');
// []
getPath(source, '');
// sourceInvalid paths throw PathSyntaxError:
parsePath('user..name');
parsePath('items[abc]');
parsePath('items[0]name');object-path-kit blocks unsafe path segments by default:
parsePath('__proto__.polluted');
parsePath('constructor.prototype');
parsePath(['safe', 'prototype']);Those calls throw UnsafePathError.
This protects helpers like setPathImmutable from accepting paths that could be used for prototype pollution in less defensive object-path utilities.
If you need to inspect or migrate unsafe strings without using them to access data, you can opt in explicitly:
parsePath('__proto__.polluted', { allowUnsafe: true });parsePath(path: string | readonly PathSegment[], options?: PathOptions): PathSegment[]Parses a path string into segments. Numbers are used for bracket indexes.
parsePath('users[0].name');
// ['users', 0, 'name']stringifyPath(segments: readonly PathSegment[], options?: PathOptions): stringSerializes path segments into a stable path string.
stringifyPath(['meta', 'user.name']);
// meta["user.name"]normalizePath(path: PathInput, options?: PathOptions): stringParses and serializes a path to one canonical representation.
normalizePath("items[0]['total-price']");
// items[0]["total-price"]validatePath(path: PathInput, options?: PathOptions): ValidatePathResultReturns a validation object instead of throwing.
validatePath('user..name');
// { valid: false, segments: [], error: PathSyntaxError }isSafePath(path: PathInput): booleanReturns false for invalid or unsafe paths.
isUnsafeSegment(segment: PathSegment): booleanChecks whether a segment is one of __proto__, prototype or constructor.
getPath(source: unknown, path: PathInput, defaultValue?: unknown, options?: PathOptions): unknownReads a value from own properties only. Missing paths return defaultValue.
getPath(data, 'user.name', 'Anonymous');hasPath(source: unknown, path: PathInput, options?: PathOptions): booleanReturns whether every segment exists as an own property.
setPathImmutable(source: unknown, path: PathInput, value: unknown, options?: PathOptions): unknownReturns a new object or array with the nested value set. Existing containers on the path are shallow-cloned.
const next = setPathImmutable(data, 'user.name', 'Ada');type PathSegment = string | number;
type PathInput = string | readonly PathSegment[];
interface PathOptions {
allowUnsafe?: boolean;
}
interface ValidatePathResult {
error?: Error;
segments: PathSegment[];
valid: boolean;
}- Only non-negative integer array indexes are supported in bracket notation.
getPathandhasPathuse own properties only.setPathImmutableis intentionally shallow along the updated path; it does not deep-clone unrelated branches.- The package ships as ESM with TypeScript declarations.
MPL-2.0