Skip to content

Object

import * as Obj from '@stopcock/fp/object'

Object operators preserve symbol keys, use own enumerable properties, and defend write paths against prototype-pollution keys.

Obj.keys(value)
Obj.values(value)
Obj.entries(value)
Obj.pick(['id', 'name'])(value)
Obj.omit(['password'])(value)
Obj.pickBy((_value, key) => key !== 'internal')(value)
Obj.omitBy(predicate)(value)

pick, omit, and predicate projections curry the keys or predicate first, so they drop straight into pipe.

import { pipe } from '@stopcock/fp'
const updated = pipe(
user,
Obj.assoc('role', 'admin'),
Obj.dissoc('temporaryToken'),
Obj.evolve({
name: (name) => name.trim(),
}),
)

mapValues, mapKeys, and invert cover whole-object transformations.

Obj.mergeWith(overrides, (left, right) => right ?? left)(defaults)
Obj.mergeDeep(overrides, {
bias: 'right',
arrays: 'replace',
})(defaults)

Deep merge options make conflict and array semantics explicit:

  • bias: choose the left or right value by default.
  • arrays: replace, concat, or merge-index.
  • onConflict: resolve non-record values with access to their property path.

Deep merge is cycle-safe for repeated object pairs.

Paths are readonly key tuples, not dotted strings:

const cityPath = Obj.pathOf<User>()('profile', 'address', 'city')
Obj.getPath(cityPath)(user) // Option<string>
Obj.getPathOrUndefined(cityPath)(user) // string | undefined
Obj.hasPath(cityPath)(user)
const moved = Obj.setPath(cityPath, 'Manchester')(user)
const upper = Obj.modifyPath(cityPath, String.toUpperCase)(user)
const nicknamePath = Obj.pathOf<User>()('profile', 'nickname')
const withoutNickname = Obj.removePath(nicknamePath)(user)

Tuple paths distinguish numeric indices from string keys, reject unsafe write segments, and infer nested values to a bounded depth. removePath accepts only optional leaves (or the empty no-op path), so its declared result can remain the original source type.

Path writes traverse only ordinary arrays and plain objects. Missing object containers are created with normal {} semantics (and missing numeric containers as arrays). Unrelated own properties, including symbols, non-enumerables, accessors, and property flags, are preserved along each cloned branch. Class instances, callable objects, and array subclasses are not traversed: nominal and callable types are rejected statically when TypeScript can prove them, and structurally indistinguishable runtime values fail with TypeError.

The literal keys __proto__, constructor, and prototype are rejected by the write APIs. A broad string key for an index signature cannot be ruled out statically, so it is checked at runtime and throws TypeError if its actual value is unsafe.

If you read the same path over and over, compile it once:

const city = Obj.compilePathOf<User>()('profile', 'address', 'city')
city.get(user) // Option<string>
city.getOrUndefined(user) // string | undefined
city.has(user)

The segments are copied and frozen when you compile, so mutating the array you passed in cannot change what the reader does. Results are identical to getPath, getPathOrUndefined, and hasPath, including a present undefined leaf staying Some(undefined).

There is deliberately no compiled write. A path write is dominated by structurally cloning each container it passes through, not by walking the path, so setPath and modifyPath already do the fast thing on their own: when every container on the branch is ordinary plain data, they skip the descriptor-by- descriptor clone. Anything with an accessor, a non-enumerable or frozen slot, an exotic prototype, or an own __proto__ property falls back to the exact clone, so the output is the same either way.