Walking a path
A chain records hops — nothing is read or copied while you build it, so the whole path is known before a single object is touched (which is what lets each level be copied at most once).
.get(key) | go into a property |
.at(index) | go into one element (negative counts from the end) |
.where(pred) | go into every element the predicate accepts |
The array hops (at, where) only exist when the focused value is an array, so
.where() on an object is a compile error, not a runtime miss.
where matches all of them
// every draft post gets the title, in ONE walk — `posts` is copied once,
// and the posts that didn't match keep their identity.
focusOn(state)
.get("posts")
.where((post) => post.draft)
.get("title")
.set("TODO");
remove follows the same rule — where(…).remove() drops every match in one pass.
Because it matches all of them, where can't stop early; when you know the position,
at(i) skips the scan.
Stepping through optional values
A path steps through a nullable value instead of being stopped by it, so an optional property in the middle doesn't make everything below it uncallable:
interface State {
profile: { name: string } | null;
}
focusOn(state).get("profile").get("name").set("Ada");
If profile really is null at runtime, development throws, naming the hop that was missing and
the whole path it could not reach:
[Ramonda lens RML001] .profile is null, so .profile.name could not be reached.
Nothing was changed.
→ Only the LAST hop creates what it names, so a gap before it cannot be walked
through. Set the intermediate value first, or `merge` the whole object into place.
It throws rather than warns because of what carrying on looks like: the root comes back unchanged, which is indistinguishable from a write that had nothing to do. A warning in a busy console is easy to walk past; an update that silently does not happen is not something to find in production.
A published build does not throw, and says nothing. Every lens diagnostic is behind __DEV__, so
the check still runs and the write still returns the root — but there is no message and no record.
That is deliberate: the text is bytes shipped to nobody, and an exception in front of a user buys
nothing the author could not have seen while writing the line.
And ramonda-check says it without running the line at all. The rule is
lens-path-through-a-gap, and it reports a WRITE through a hop your types declare
?, | null or | undefined when there is more path after it. A read is left alone: value()
through a missing hop answers undefined and values() answers [], which is what they are for.
Prove the value is there and the report goes quiet, because then the write is correct:
if (state.profile) {
focusOn(state).get("profile").get("name").set("Ada");
}
That matters more than a second report of the same thing. A gap is the state nobody sets up locally — a fresh account, a failed fetch, a first render — so the throw arrives on somebody else's machine and the rule arrives on the line as you type it.
So the rule is: the middle of a path has to be there. Only the LAST hop creates what it names, so set
the intermediate value first, or merge the whole object into place, whenever a middle hop is
genuinely optional rather than merely typed that way.
Narrowing a type
focusOn(state)
.get("values")
.where<string>((value) => typeof value === "string")
.update((value) => value.toUpperCase()); // value is string
Give where an explicit type argument to narrow.
Reading
focusOn(state).get("posts").where((p) => p.id === 102).get("title").value();
// "Second post" — or undefined if the path resolves to nothing
focusOn(state).get("posts").where(Boolean).get("title").values();
// ["First post", "Second post"]
value() is the first focused value, values() is all of them. Reads are silent —
asking for a path that doesn't exist is a fair question with a fair answer. It's
writes that report a miss, because there a miss means an edit didn't happen.
value() answers undefined both for a path that resolved to nothing and for a property
that is there and holds undefined — its result can't tell them apart. When the difference
matters, count instead: values().length is 0 for a miss and 1 for a present
undefined.
Next
- Updating — the operations, and forking a path with
and. - Messages you might see — every development message, and its cause.