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.
Keys and projections
Section titled “Keys and projections”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.
Immutable updates
Section titled “Immutable updates”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, ormerge-index.onConflict: resolve non-record values with access to their property path.
Deep merge is cycle-safe for repeated object pairs.
Typed paths
Section titled “Typed paths”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 | undefinedObj.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.
Compiled paths
Section titled “Compiled paths”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 | undefinedcity.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.