@trh/lens
v1.0.1
Published
Type-safe optics for reading, mutating, and immutably updating deeply nested data structures in TypeScript.
Downloads
235
Readme
@trh/lens
Type-safe optics for reading, mutating, and immutably updating deeply nested data structures in TypeScript.
npm install @trh/lensCore API
The library exports a single Lens namespace with three operations. All three take a data object and a lens callback that describes the path to a target value.
import { Lens } from "@trh/lens";Lens.get -- read a value
const data = { users: [{ name: "Alice" }, { name: "Bob" }] };
Lens.get(data, $ => $("users")(0)("name")); // "Alice"
Lens.get(data, $ => $("users")(1)("name")); // "Bob"Lens.mutate -- modify in place
Mutates the original object. Returns void.
Lens.mutate(data, $ => $("users")(0)("name"), "Alicia");
// data.users[0].name is now "Alicia"Lens.apply -- immutable update
Returns a new object with structural sharing. The original is untouched.
const updated = Lens.apply(data, $ => $("users")(0)("name"), "Alicia");
// updated.users[0].name === "Alicia"
// data.users[0].name === "Alice" (unchanged)
// updated.users[1] === data.users[1] (shared)Updater functions
Both mutate and apply accept either a static value or an updater function:
Lens.mutate(data, $ => $("users")(0)("name"), (prev, index, ctx) => prev.toUpperCase());The updater receives:
prev-- the current value at the target locationindex-- the iteration index (0 for non-iterated paths, element index insideeach())ctx-- aLens.Contextwith{ path, index, count }for full traversal metadata
In apply, prev is typed as DeepReadonly to discourage accidental mutation.
Navigation
Property access
Navigate into object properties by calling the lens proxy with a string key:
Lens.get(data, $ => $("address")("city"));Array index access
Navigate into arrays by calling with a numeric index, or using .at():
Lens.get(data, $ => $("items")(0));
Lens.get(data, $ => $("items").at(-1)); // last elementBoth support negative indices (counted from the end).
each() -- iterate arrays
each() fans out over every element. With get, the result is collected into an array. With mutate/apply, every element is updated.
const data = { users: [{ name: "Alice" }, { name: "Bob" }] };
Lens.get(data, $ => $("users").each()("name"));
// ["Alice", "Bob"]
Lens.mutate(data, $ => $("users").each()("name"), name => name.toUpperCase());
// all names uppercased in-placeeach() also accepts a callback for more complex sub-navigation:
const data = { groups: [{ items: [{ x: 1 }, { x: 2 }] }, { items: [{ x: 3 }] }] };
Lens.get(data, $ => $("groups").each($g => $g("items").each()("x")));
// [1, 2, 3]Filtering
Filters are chainable and narrow which array elements are affected. They apply to subsequent each() or at() calls.
where() -- predicate-based filtering
const data = { users: [{ name: "Alice", age: 30 }, { name: "Bob", age: 25 }] };
Lens.get(data, $ => $("users").where($ => [$("age"), ">=", 30]).each()("name"));
// ["Alice"]See Predicate Reference for the full operator list.
filter() -- function-based filtering
Lens.get(data, $ => $("users").filter(u => u.age >= 30).each()("name"));
// ["Alice"]slice()
Lens.get(data, $ => $("items").slice(0, 2).each());sort()
Sort by a lens path with a direction:
Lens.get(data, $ =>
$("users").sort($ => $("age"), "desc").each()("name")
);Direction can be "asc", "desc", or { direction: "asc" | "desc", nullish?: "first" | "last" }.
Sort also accepts a raw comparator:
Lens.get(data, $ =>
$("users").sort((a, b) => a.age - b.age).each()("name")
);Chaining filters
Filters compose -- you can chain where, filter, sort, and slice in any order before each() or at():
Lens.get(data, $ =>
$("users")
.where($ => [$("active"), "?"])
.sort($ => $("name"), "asc")
.slice(0, 10)
.each()("name")
);Read-only Accessors
These return derived values and cannot be used as mutation targets.
size()
Works on strings (character count), arrays, Maps, Sets, and plain objects (key count):
Lens.get(data, $ => $("users").size()); // 2
Lens.get(data, $ => $("name").size()); // 5length()
Array/string .length:
Lens.get(data, $ => $("items").length());keys(), values(), entries()
Work on plain objects and Maps:
Lens.get(data, $ => $("config").keys()); // ["a", "b", ...]
Lens.get(data, $ => $("config").values());
Lens.get(data, $ => $("config").entries()); // [["a", 1], ["b", 2], ...]transform()
Apply an arbitrary function. Read-only -- cannot be a mutation target:
Lens.get(data, $ => $("name").transform(s => s.split(" ")));
// ["Alice", "Smith"]Map and Set Support
Map
const data = { lookup: new Map([["x", { value: 1 }]]) };
// Read
Lens.get(data, $ => $("lookup").get("x")("value")); // 1
// Mutate
Lens.mutate(data, $ => $("lookup").get("x")("value"), 42);
// Read-only accessors
Lens.get(data, $ => $("lookup").has("x")); // true
Lens.get(data, $ => $("lookup").keys()); // ["x"]
Lens.get(data, $ => $("lookup").size()); // 1get() is the only Map accessor that supports mutation/apply -- it navigates into the value.
Set
Lens.get(data, $ => $("tags").has("admin")); // true/false
Lens.get(data, $ => $("tags").size()); // numberPredicate Reference
Predicates are used inside where(). They are tuples of [subject, operator, operand?].
The subject is a lens path: $("fieldName"). The operand can be a literal value or another lens path.
Unary operators
| Operator | Meaning |
|----------|---------|
| ? | truthy |
| !? | falsy |
.where($ => [$("active"), "?"])Equality
| Operator | Meaning |
|----------|---------|
| = | equals (protocol-aware, then loose ==) |
| != | not equals |
| == | strict equals (===) |
| !== | not strict equals |
Ordering
| Operator | Meaning |
|----------|---------|
| > | greater than |
| < | less than |
| >= | greater or equal |
| <= | less or equal |
Negated forms (!>, !<, !>=, !<=) are also available. Ordering uses @trh/symbols Compare protocol, then falls back to native numeric/string comparison.
Range
| Operator | Meaning |
|----------|---------|
| >< | between, exclusive |
| >=< | between, inclusive |
Range predicates take four elements:
.where($ => [$("age"), ">=<", 18, 65])String operators
| Operator | Meaning |
|----------|---------|
| % | contains |
| %^ | contains (case-insensitive) |
| %_ | starts with |
| %^_ | starts with (case-insensitive) |
| _% | ends with |
| _%^ | ends with (case-insensitive) |
| ~ | regex match |
Collection operators
| Operator | Meaning |
|----------|---------|
| # | contains element (arrays, Sets, or @trh/symbols Containable) |
| : | type check (via startsWith on resolved type string) |
Any-of / All-of variants
Append \| to test if any operand matches, or & if all must match. The operand becomes an array:
.where($ => [$("status"), "=|", ["active", "pending"]]) // equals any of
.where($ => [$("tags"), "#&", ["admin", "verified"]]) // contains all of
.where($ => [$("name"), "%|", ["Ali", "Bob"]]) // contains any of
.where($ => [$("type"), ":|", ["string", "number"]]) // type is any ofLogical combinators
Available on the $ parameter inside where():
.where($ => $.or(
[$("age"), ">", 30],
[$("role"), "=", "admin"]
))
.where($ => $.and(
[$("active"), "?"],
$.not([$("banned"), "?"])
))
.where($ => $.xor(
[$("a"), "?"],
[$("b"), "?"]
))Custom Accessors (LensNav)
Objects can define custom lens-navigable accessors using @trh/symbols's LensNav symbol. This lets domain types participate in lens navigation with full get/mutate/apply support.
import { TrhSymbols } from "@trh/symbols";
class Grid {
cells: number[][];
get [TrhSymbols.LensNav]() {
return {
cell: {
access: (row: number, col: number) => this.cells[row][col],
mutate: (value: number, row: number, col: number) => {
this.cells[row][col] = value;
},
apply: (value: number, row: number, col: number) => {
const copy = new Grid();
copy.cells = this.cells.map(r => [...r]);
copy.cells[row][col] = value;
return copy;
},
},
};
}
}
Lens.get(grid, $ => $("grid").cell(0, 1));
Lens.mutate(grid, $ => $("grid").cell(0, 1), 42);Each accessor object needs one of:
access(...args) => value-- deterministic navigation, supports reading and mutationcompute(...args) => value-- derived/computed value, read-only (Target = never)
And optionally (only meaningful with access):
mutate(newValue, ...args)-- used byLens.mutateapply(newValue, ...args) => newOwner-- used byLens.apply
Type System
The lens callback parameter ($) is typed differently depending on the operation:
Lens.getprovides aSelector-- all navigation is available, the return type tracks whatgetwill produceLens.mutateprovides aMutator-- only paths that can be written to are valid terminal targetsLens.applyprovides anApplier-- same asMutator
Read-only accessors (transform, size, keys, where, filter, etc.) return lenses with Target = never, which makes them structurally incompatible with MutatorFor / ApplierFor. This means the type system prevents you from using a read-only path as a mutation target:
// Type error: transform() returns a read-only lens
Lens.mutate(data, $ => $("name").transform(s => s.toUpperCase()), "x");License
UNLICENSE
