Skip to content

Add Optic.getFocus to report where an optic focuses - #10248

Open
fubhy wants to merge 12 commits into
mainfrom
feat/optic-paths
Open

fubhy wants to merge 12 commits into
mainfrom
feat/optic-paths

Conversation

@fubhy

@fubhy fubhy commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Why

We're designing a test runner for Effect. It isn't published yet; this PR prepares one piece of core it will need. In its design, a failed assertion carries data, not a rendered diff: a list of mismatches, each with its path from the root of the value under test, a code, and the expected and actual values. Reporters render that list for humans, write it to JSON or JUnit, or hand it to an agent. The path is how a reader, human or agent, finds the bad value without re-reading the whole object.

One of the planned matchers runs a matcher on the part of a value an optic focuses on. Illustrative, since the API isn't final:

const shipped = Optic.id<State>().key("orders").at(0).key("status").tag("Shipped")

Assert.equal(state, Matcher.focus(shipped, { _tag: "Shipped", trackingId: "A1" }))

Like every other matcher, focus should report mismatches at their real path from the root: ["orders"][0]["status"]<Shipped>["trackingId"]. With effect/Optic as it is, it can't, because an optic doesn't say where it looks or where it stopped:

  • A failed getResult returns the failing step's issue unchanged, relative to that step: tag fails with a bare InvalidValue and at(5) fails with Pointer([5], MissingKey). Neither knows the steps before it.
  • The failed step's input is absent, so the matcher can't say "got { _tag: "Pending" }".
  • A Traversal returns the elements it collected, minus the ones it skipped. Index k in that array isn't index k in the source, so a mismatch at focus[0] can't be traced back to orders[1].
  • An optic has no public path, name or toString.

The best a matcher can do today is report relative to the focus, under a fixed label: focus[0]: expected "A1", got "B2". The reader can't tell which order that is. Our prototype does exactly that.

Nothing in this PR depends on the runner. getFocus is useful to anything that reports on optics, such as form validation errors or logging which field was updated.

Requirement

What the planned focus matcher needs from core. Please weigh other designs against this list, not just against ours:

  1. For a successful focus: the focused value, plus the path from the root to it, so mismatches inside the focus can be prefixed.
  2. For a failed focus: where it failed (the path to the step that couldn't focus), what that step received (for "got X"), and why (the issue).
  3. For a Traversal: the source path of each focused element, so a mismatch at focus[k] maps back to the real element.
  4. A path that includes tag steps, since they say which variant was expected.
  5. No change to what getResult returns. Existing callers may depend on it.

Summary

One new function next to getResult. It walks the same steps and records where it goes:

Optic.getFocus(s, optic) // or getFocus(optic)(s)
  : Result<Focus<A>, FocusFailure>

type Segment =
  | { _tag: "Key"; key: PropertyKey }        // key, optionalKey, at, traversal index (a number)
  | { _tag: "Tag"; tag: LiteralValue }       // tag
  | { _tag: "Opaque" }                       // makeIso/makeLens/makePrism/makeOptional, entries()

interface Focus<A>    { value: A; path: Segment[]; elements: Segment[][] | undefined }
interface FocusFailure { path: Segment[]; input: unknown; issue: SchemaIssue.Issue } // issue === what getResult fails with
  • some/success/failure add a Tag and then a Key (<Some>["value"]); none adds <None>.
  • Checks, refine, notUndefined, pick and omit keep the location and add nothing.
  • A step into the array a traversal collected (for example key(0) after forEach) adds Opaque; use elements for each element's source path.
  • forEach used to wrap the whole chain in front of it into one opaque makeOptional. It's now a step of the optic, so the walker can see through it. Its get/set behaviour is unchanged.
 forEach(f)
-  makeOptional(s => map(this.getResult(s), collect), (bs, s) => … this.replaceResult(out, s))
+  compose(this.node, [EachNode(f(id()))])

What the planned focus matcher would do with it:

focus(optic, expected)(actual)
  r = getFocus(actual, optic)
  failure  → mismatches(r.issue) under r.path, actual = r.input
  success  → compare(r.value, expected) under r.path
             for a traversal: leading Index(k) → r.elements[k]

Alternatives considered

Option Why not
Read the internal optic.node in the matcher library Depends on an internal field; at/tag/optionalKey keys live only in closures; a traversal's indices still need re-walking
A static read-only optic.path (what the research first recommended) Can't say which step failed (at("orders") failing would still report ["orders"][0]["status"]), can't give a traversal's real indices, and has no input for "got X"
Make getResult failures root-relative (wrap them in Pointer(prefix, issue)) Changes getResult's output (requirement 5); Pointer paths are PropertyKey[], so tag steps can't appear (requirement 4); no input and no traversal indices
Focus helpers in the matcher library (focus.key("a").index(0)) No core change, but it adds a second vocabulary for focusing next to Optic

Open to better ideas, for example making make* accept the segments a custom optic takes, so it isn't Opaque.

Evidence

Run against { orders: [{ status: Pending }, { status: Shipped "A1" }, { status: Shipped "B2" }] }. Paths are joined for readability:

id<S>().key("orders").at(0).key("status").tag("Shipped").key("trackingId")
  Before  getResult → Failure(InvalidValue { expected: '"Shipped" tag' })                  // where? got what?
  After   getFocus  → Failure { path: ["orders"][0]["status"], input: { _tag: "Pending" }, issue: (same) }

id<S>().key("orders").at(5)
  Before  getResult → Failure(Pointer([5], MissingKey))
  After   getFocus  → Failure { path: ["orders"], input: [ …3 orders ], issue: (same) }

id<S>().key("orders").forEach(o => o.key("status").tag("Shipped").key("trackingId"))
  Before  getResult → Success(["A1", "B2"])                                                  // focus[0] is orders[?]
  After   getFocus  → Success { value: ["A1", "B2"], path: ["orders"],
                                elements: [ ["orders"][1]["status"]<Shipped>["trackingId"],
                                            ["orders"][2]["status"]<Shipped>["trackingId"] ] }
  • New getFocus tests in packages/effect/test/Optic.test.ts check that each failure's issue matches getResult's.
  • All existing Optic and Schema tests pass unchanged, including the forEach replace and arity cases.
  • pnpm check, pnpm jsdocs --check and the doctests pass.
  • pnpm lint-fix wasn't run; the touched files were formatted with dprint and linted with oxlint directly.

Merge Danger

Door: two-way

getFocus, Segment, Focus and FocusFailure are new and tagged @stability experimental, so their shape can still change in a patch once the test runner's matchers use them. getResult, replace and modify return exactly what they did before.

Blast Radius: forEach

forEach is now built differently inside the module: a step of the composed optic rather than a wrapper around it. The existing tests cover its get, replace, arity-mismatch and inner-failure behaviour. pick/omit and the built-in prisms now get their path metadata internally instead of going through the public makeLens/makePrism; their behaviour is the same.

Closes EFF-3257

getFocus returns the same value or issue as getResult, together with the
path from the root. A failure also carries the path to, and the input of,
the step that failed. Traversals report each element's source index.

key/optionalKey/at add a Key segment, tag adds a Tag segment, and the
some/none/success/failure prisms add Tag (+ Key) segments. Checks, refinements,
pick and omit keep the location. make* optics and entries() add an Opaque
segment. forEach is now a step of the optic instead of an opaque wrapper
around the chain before it, so the walker can see through it.
@fubhy

fubhy commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

@effect-bot

@effect-bot

Copy link
Copy Markdown
Contributor

Thanks! I've queued a takeover of this PR in our tracker (EFF-3257). The team will review the Optic.getFocus design and follow up here.

Drop the rule that redirected keys of a traversal's collected array to the
element's source: any step into that array now adds an Opaque segment.
Built-in steps carry their segments explicitly, with [Opaque] for make*
optics and entries(), so the walker no longer special-cases undefined.
Segment.Tag.tag is now a string.
@changeset-bot

changeset-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 755b1a8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added the 4.0 label Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.41 KB 39.41 KB 0.00 KB (0.00%)
basic.ts 7.23 KB 7.23 KB 0.00 KB (0.00%)
batching.ts 11.08 KB 11.08 KB 0.00 KB (0.00%)
brand.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
cache.ts 11.54 KB 11.54 KB 0.00 KB (0.00%)
config.ts 22.84 KB 22.84 KB 0.00 KB (0.00%)
differ.ts 22.00 KB 22.00 KB 0.00 KB (0.00%)
http-client.ts 26.93 KB 26.93 KB 0.00 KB (0.00%)
http-router.ts 37.00 KB 37.00 KB 0.00 KB (0.00%)
logger.ts 12.01 KB 12.01 KB 0.00 KB (0.00%)
metric.ts 9.47 KB 9.47 KB 0.00 KB (0.00%)
optic.ts 7.24 KB 7.10 KB +0.15 KB (+2.06%)
pubsub.ts 16.02 KB 16.02 KB 0.00 KB (0.00%)
queue.ts 12.82 KB 12.82 KB 0.00 KB (0.00%)
schedule.ts 11.80 KB 11.80 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 14.14 KB 14.14 KB 0.00 KB (0.00%)
schema-binary.ts 41.26 KB 41.26 KB 0.00 KB (0.00%)
schema-class.ts 21.73 KB 21.73 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.60 KB 29.60 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 27.99 KB 27.99 KB 0.00 KB (0.00%)
schema-string-transformation.ts 15.35 KB 15.35 KB 0.00 KB (0.00%)
schema-string.ts 12.34 KB 12.34 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.27 KB 16.27 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 38.95 KB 38.95 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 26.15 KB 26.15 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 20.90 KB 20.90 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 21.05 KB 21.05 KB 0.00 KB (0.00%)
schema-toFormatter.ts 21.14 KB 21.14 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 26.17 KB 26.17 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 21.23 KB 21.23 KB 0.00 KB (0.00%)
schema.ts 20.82 KB 20.82 KB 0.00 KB (0.00%)
stm.ts 14.20 KB 14.20 KB 0.00 KB (0.00%)
stream.ts 10.38 KB 10.38 KB 0.00 KB (0.00%)

The walker now carries a tree of locations, so an element collected by a
nested traversal keeps its own element locations and a step into it is
reported as Opaque instead of an invented source index.

Segment.Tag.tag is SchemaAST.LiteralValue again, matching what .tag()
accepts.
@fubhy
fubhy requested a review from gcanti November 1, 2026 09:38
effect-bot and others added 7 commits November 1, 2026 09:45
Every non-traversal step now carries its own path segments, so the walker
runs one get branch and one segment loop instead of a switch per node kind.
The location tree stays: it is the uniform model that keeps nested
traversals sound.
Name the success and failure models Diagnostics and DiagnosticsFailure, and update examples and tests to use the diagnostic reader name.
Read, update, and merge PathNode key segments directly instead of retaining a duplicate PropertyKey path.
Expose a shared Location model so nested traversal indices remain available to reporters. Include the current element mappings when a later read fails, and cover both cases with regression tests.
Use common sequencing, failure propagation, and traversal collection while skipping diagnostic allocation on ordinary reads. Cover nested replacements and ensure each traversal element is read once per evaluation.
Clarify traversal index segments, update the consumer release note to getWithDiagnostics, and align the new API versions with the minor-release policy for public additions.
Skip the final EachNode getter during setter compilation. Its setter already finds the matching elements, so collecting the focused values first adds an unused traversal. Preserve reads needed by subsequent optic steps.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants