{"id":50747386,"url":"https://github.com/eschizoid/telescope","last_synced_at":"2026-06-26T01:01:04.107Z","repository":{"id":360268647,"uuid":"1249380130","full_name":"eschizoid/telescope","owner":"eschizoid","description":"Optics-based DSL for Java records and POJOs. One type for deep navigation, immutable update, bidirectional mapping, and effects across records + plain POJOs + Lombok. Optional compile-time codegen.","archived":false,"fork":false,"pushed_at":"2026-06-24T00:44:24.000Z","size":3740,"stargazers_count":16,"open_issues_count":3,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-24T01:21:09.426Z","etag":null,"topics":["annotation-processor","codegen","deep-copy","dsl","functional-programming","immutable","java","lens","lombok","optics","records"],"latest_commit_sha":null,"homepage":"https://central.sonatype.com/artifact/io.github.eschizoid/telescope-core","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/eschizoid.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-25T16:30:16.000Z","updated_at":"2026-06-24T00:44:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/eschizoid/telescope","commit_stats":null,"previous_names":["eschizoid/telescope"],"tags_count":24,"template":false,"template_full_name":null,"purl":"pkg:github/eschizoid/telescope","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eschizoid%2Ftelescope","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eschizoid%2Ftelescope/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eschizoid%2Ftelescope/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eschizoid%2Ftelescope/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/eschizoid","download_url":"https://codeload.github.com/eschizoid/telescope/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eschizoid%2Ftelescope/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34716326,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["annotation-processor","codegen","deep-copy","dsl","functional-programming","immutable","java","lens","lombok","optics","records"],"created_at":"2026-06-10T22:30:38.409Z","updated_at":"2026-06-26T01:01:04.064Z","avatar_url":"https://github.com/eschizoid.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"img/logo.png\" alt=\"telescope — optics-based DSL for Java records and POJOs\" width=\"320\" /\u003e\n\u003c/p\u003e\n\n# telescope\n\n**Build a typed path through your nested data — then read, update, or convert through it. Bidirectionally. One line.**\n\nWorks on Java records, POJOs, and Lombok `@Data` classes. Compile-time codegen is optional. Spring Boot starter and\nQuarkus extension ship as separate artifacts.\n\n**Coming from MapStruct? This is the upgrade.** MapStruct's architecture is a decade old — string-keyed `@Mapping`\nannotations, compile-time-only, one direction per interface, mapping, and nothing else. Telescope does that same job at\nthe **same codegen speed** (a tie on the shape real services run — deep nesting with list traversals), but on a modern\nfoundation: typed method references the compiler checks (a typo is a `javac` error, not a processor warning),\nbidirectional from a single declaration, runtime _or_ codegen. Then it keeps going where MapStruct structurally stops —\ndeep navigation, effectful update, sealed-root dispatch, multi-source merge, JPA-cycle and Hibernate-`LAZY` handling,\nall from one `Telescope\u003cS, A\u003e` type. Same speed, compile safety MapStruct can't give you, and a strictly larger surface.\n[See it row by row →](#how-it-compares-to-mapstruct)\n\n[![JVM 17+](https://img.shields.io/badge/JVM-17%2B-brightgreen.svg?\u0026logo=openjdk)](https://openjdk.org/projects/jdk/17/)\n[![Build](https://github.com/eschizoid/telescope/actions/workflows/ci.yaml/badge.svg)](https://github.com/eschizoid/telescope/actions/workflows/ci.yaml)\n[![Codecov](https://codecov.io/gh/eschizoid/telescope/graph/badge.svg?token=a235ea8b-e6dc-45c6-8fea-e5050940c5d4)](https://codecov.io/gh/eschizoid/telescope)\n[![Maven Central](https://img.shields.io/maven-central/v/io.github.eschizoid/telescope-core.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.eschizoid/telescope-core)\n[![Javadoc](https://javadoc.io/badge2/io.github.eschizoid/telescope-core/javadoc.svg?color=purple)](https://javadoc.io/doc/io.github.eschizoid/telescope-core)\n[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\n\n---\n\n## Install\n\n```kotlin\n// Gradle (Kotlin DSL)\ndependencies {\n  implementation(\"io.github.eschizoid:telescope-core:1.0.5\")\n}\n```\n\n```xml\n\u003c!-- Maven --\u003e\n\u003cdependency\u003e\n  \u003cgroupId\u003eio.github.eschizoid\u003c/groupId\u003e\n  \u003cartifactId\u003etelescope-core\u003c/artifactId\u003e\n  \u003cversion\u003e1.0.5\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nThat's the runtime. Compile-time codegen, Spring Boot starter, Quarkus extension, and JPMS setup are\n[listed below](#additional-artifacts).\n\n---\n\n## First 5 minutes\n\nYou have nested data and you want to update a field deep inside without writing copy constructors:\n\n```java\nrecord Address(String city, String zip) {}\n\nrecord User(String name, Address address) {}\n\n// 1. Build a typed path once.\nfinal var userCity = Telescope.of(User.class).field(User::address).field(Address::city);\n\n// 2. Use it for reading, updating, anything else.\nString city = userCity.read(alice); // → \"Springfield\"\n\nUser shouted = userCity.update(alice, String::toUpperCase); // → city becomes \"SPRINGFIELD\"\n```\n\nThat's the whole model. Every other capability — mapping between types, navigating containers, lifting through\nasync/validation effects — is the same path with a different terminal method.\n\n**What's next:**\n\n- Navigate `List\u003cX\u003e` / `Optional\u003cX\u003e` / `Map\u003cK, V\u003e` → [Cookbook](#cookbook)\n- Convert between types (record↔record, POJO↔record) → [Type conversion](#type-conversion)\n- Lift through async / validated / either / optional effects → [Effects](#effects)\n- Compile-time-bound navigators for hot paths →\n  [Compile-time codegen](#compile-time-reflection-free-navigation-focus--beanfocus)\n\n---\n\n## Picking your entry point\n\nTwo questions decide it: are you working with **records** or **POJOs**, and do you want to **navigate** one type in\nplace or **convert** between two types?\n\n| You want to…                          | Records                                       | POJOs                                | POJO ⇄ record                                  |\n| ------------------------------------- | --------------------------------------------- | ------------------------------------ | ---------------------------------------------- |\n| **Navigate \u0026 update** in place        | `Telescope.of(R.class)`                       | `Telescope.ofBean(P.class)`          | bridge first (below), then navigate the record |\n| **Convert / map** between two types   | `Telescope.map(A.class, B.class, to(...), …)` | `Telescope.map(A.class, B.class, …)` | `Telescope.map(P.class, R.class, …)`           |\n| **Reflection-free** (compile-checked) | `@Focus` (navigate)                           | `@BeanFocus` (navigate)              | `@Bridge` (convert, any pair)                  |\n\nConversions are bidirectional `Iso`s, so any cell in the middle row composes into a longer navigation path with\n`.then(...)`. Mismatched names get an explicit `Mapping.to(srcAccessor, tgtAccessor)` row in the `Telescope.map(...)`\ncall; classes the auto-detect can't handle get a `WriteHint.writeBean(target, strategy)` row. Both are covered under\n[Working with POJOs](#working-with-pojos).\n\n---\n\n## More 30-second vignettes\n\n### Records\n\n```java\nimport io.github.eschizoid.telescope.Telescope;\n\nrecord Address(String city, String zip) {}\n\nrecord User(String name, int age, String email, Address address) {}\n\nrecord Team(String name, List\u003cUser\u003e users) {}\n\nrecord Department(String name, List\u003cTeam\u003e teams) {}\n\nrecord Company(String name, List\u003cDepartment\u003e departments) {}\n```\n\nOne task — lowercase every user's email in the whole company tree — done both ways.\n\n#### Without telescope\n\n```java\nfinal Company lowered = new Company(\n  company.name(),\n  company\n    .departments()\n    .stream()\n    .map((d) -\u003e\n      new Department(\n        d.name(),\n        d\n          .teams()\n          .stream()\n          .map((t) -\u003e\n            new Team(\n              t.name(),\n              t\n                .users()\n                .stream()\n                .map((u) -\u003e new User(u.name(), u.age(), u.email().toLowerCase(), u.address()))\n                .toList()\n            )\n          )\n          .toList()\n      )\n    )\n    .toList()\n);\n```\n\n#### With telescope\n\n```java\nfinal Telescope\u003cCompany, String\u003e emails = Telescope.of(Company.class)\n  .each(Company::departments)\n  .each(Department::teams)\n  .each(Team::users)\n  .field(User::email);\n\nfinal Company lowered = emails.update(company, String::toLowerCase);\n```\n\n~25 lines of manual reconstruction — every constructor enumerated, every untouched field threaded through by hand —\nversus one reusable path. And the path isn't single-use:\n\n```java\nemails.toList(company);   // List\u003cString\u003e of every email\nemails.count(company);    // how many\n```\n\n### Mapping\n\nSame tree, now translate `Company` to a partner-facing `CompanyDto` with a few renamed fields — **one definition, both\ndirections**:\n\n```java\nrecord AddressDto(String town, String postalCode) {}\n\nrecord UserDto(String fullName, int age, String email, AddressDto address) {}\n\nrecord TeamDto(String name, List\u003cUserDto\u003e users) {}\n\nrecord DepartmentDto(String name, List\u003cTeamDto\u003e teams) {}\n\nrecord CompanyDto(String name, List\u003cDepartmentDto\u003e departments) {}\n\nfinal Mapper\u003cCompany, CompanyDto\u003e dtoMapper = Telescope.mapper(\n  Company.class,\n  CompanyDto.class,\n  to(User::name, UserDto::fullName), // rename, applies everywhere User↔UserDto recurses\n  to(Address::city, AddressDto::town),\n  to(Address::zip, AddressDto::postalCode)\n);\n\nfinal CompanyDto dto = dtoMapper.forward(company);\n\nfinal Company restored = dtoMapper.backward(dto); // ← bidirectional from one definition\n```\n\nSame-name fields auto-recurse (`User::email`, `User::age`, all the list/tree wiring). You only name what changes.\nMapStruct needs a second `@Mapper` interface for the inverse direction; telescope does not.\n\nNeed a flat field to land at a nested target leaf — MapStruct's `@Mapping(source = \"flat\", target = \"a.b.c\")`? The\ncodegen-emitted navigator is a first-class argument to `Mapping.to(...)`:\n\n```java\nTelescope.mapper(Cart.class, CartDto.class,\n  to(Cart::customerName, CartDtoTelescope.of().shipping().recipient().fullName()));\n```\n\nEvery hop is typed; `javac` and the IDE refactor follow each step.\n\nNeed eager literals or per-call computed values stamped at the target — MapStruct's `@Mapping(constant = \"...\")` and\n`@Mapping(expression = \"java(...)\")`? Declared in the same `Telescope.mapper(...)` call:\n\n```java\nTelescope.mapper(Order.class, OrderDto.class,\n  to(Order::id,                OrderDto::id),\n  constant(OrderDto::tenant,   \"production\"),       // eager literal\n  compute(OrderDto::createdAt, Instant::now),      // fresh per call\n  compute(OrderDto::traceId,   UUID::randomUUID),\n  compute(OrderDto::metadata,  HashMap::new));     // fresh container per call\n```\n\n`constant` captures once at row construction; `compute` invokes the supplier each forward call (the right choice\nwhenever a literal would share one mutable reference — `HashMap::new`, `Instant::now`, `UUID::randomUUID`). Both are\nforward-only by design; backward direction silently drops the slot, matching MapStruct semantics.\n\n### Beans\n\nPOJOs don't need a mirror record. Navigate the bean directly with `ofBean`; `set`/`update` rebuild it immutably, so the\noriginal is never mutated:\n\n```java\nclass Address {\n  /* getCity()/setCity(), getZip()/setZip() */\n}\n\nclass User {\n  /* getName(), getAddress() + setters */\n}\n\nfinal User moved = Telescope.ofBean(User.class)\n  .field(User::getAddress)\n  .field(Address::getCity)\n  .update(user, String::toUpperCase); // new User; `user` untouched\n```\n\nPrefer to stay in records? Convert a POJO with `Telescope.map(Pojo.class, Record.class, ...)` and navigate that — see\n[Working with POJOs](#working-with-pojos).\n\nThat's the library. No `Iso`, `Lens`, `Prism`, `Affine`, `Traversal`, `Getter`, `Setter`, `Fold` in user-facing code.\n\n---\n\n## Examples\n\nFive runnable demos cover the surface — pick the one matching what you're evaluating:\n\n| Module                                                                         | Stack                         | Pick when                                                                                                     |\n| ------------------------------------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------- |\n| [`examples/library/`](examples/library/)                                       | plain Java, no framework      | You want to see what the DSL does in isolation — 10 atomic capability demos (`*Demo.java` mains)              |\n| [`examples/springboot/order-jpa/`](examples/springboot/order-jpa/)             | Spring Boot + JPA + Hibernate | You want the kitchen sink — eight endpoints, one realistic `Order` domain, every telescope angle on one stack |\n| [`examples/springboot/product-starter/`](examples/springboot/product-starter/) | Spring Boot autoconfig        | You want zero-wiring registry discovery — drop `@Bean Mapper\u003cA, B\u003e` declarations and the starter indexes them |\n| [`examples/springboot/org-chart/`](examples/springboot/org-chart/)             | Spring Boot + JPA cycles      | You have a self-referencing domain (org charts, threads, graphs) and want to see cycle-safe mapping           |\n| [`examples/springboot/invoicing/`](examples/springboot/invoicing/)             | `@Bridge` codegen             | You want zero-reflection compile-time-bound conversion on a hot path                                          |\n\n**Where to start.** If you're evaluating telescope and want the broadest view of what it can do, lead with\n[`order-jpa/`](examples/springboot/order-jpa/) — it's the kitchen sink. If you want to see telescope without any\nframework wrapping it, browse [`examples/library/`](examples/library/) first. The other three Spring Boot demos are\nfocused follow-ups for specific concerns.\n\nSee [`examples/springboot/README.md`](examples/springboot/README.md) for the full per-module guide with endpoint maps,\ncapability lists, vs-MapStruct callouts, and benchmark cross-links.\n\n---\n\n## What it is _not_\n\n- **Not bound to MapStruct's architecture.** MapStruct is a decade-proven framework, but its design is string-keyed\n  `@Mapping` annotations, compile-time-only, one direction per interface, and mapping is the whole job. Telescope is\n  typed optics: method-reference rows the compiler checks, bidirectional from one declaration, runtime _or_ codegen, and\n  mapping is one capability among navigation, deep update, effectful update, and sealed dispatch. The codegen path runs\n  at the **same performance class** — a tie at real-service depth (see [How it compares](#how-it-compares-to-mapstruct))\n  — over a strictly larger, more modern surface. It covers every common `@Mapping(...)` shape — same-name auto, renames,\n  typed transforms, nested mappers, flat → nested-path correspondences, eager literals, computed values, forward-only\n  mappers, multi-source merge, by-name enum mapping, null-coalescing defaults, lifecycle hooks, Spring/Quarkus\n  autoconfig. And it reaches the shapes MapStruct's architecture can't express. MapStruct still leads on raw maturity\n  and a handful of declarative features (inline `expression = \"java(...)\"` bodies, qualifier dispatch, full\n  `@SubclassMapping`, `@MappingTarget` update-in-place);\n  [When MapStruct is the right pick](#when-mapstruct-is-the-right-pick) is honest about those. The\n  [full row-by-row comparison](#how-it-compares-to-mapstruct) has the rest.\n\n- **Not a fuzzy auto-mapper.** `Telescope.map(...)` matches fields by exact name and type, nothing more — no fuzzy name\n  heuristics, no flattening, no inferred relationships (that's ModelMapper / Dozer territory, and they lost to MapStruct\n  for good reasons). Anything that isn't an exact name match you declare yourself with a `Mapping.to(srcAcc, tgtAcc)` or\n  `Mapping.via(srcAcc, tgtAcc, nestedMapper)` row.\n- **Not category theory.** Internally it's a Monocle-style Traversal, but `Iso`, `Lens`, `Prism`, `Affine`, and\n  `Traversal` are all package-private behind a JPMS boundary. You read, write, update, traverse, convert, and lift\n  through one `Telescope\u003cS, A\u003e` type — you never have to type the academic words.\n\n---\n\n## How it compares to MapStruct\n\nSame job, newer architecture. MapStruct is a **mapping framework** built on string-keyed `@Mapping` annotations,\ncompile-time-only generation, and one direction per interface — purpose-built for flat `Entity → Dto` conversion.\nTelescope is an **optics DSL** where that same mapping is one capability among navigation, deep updates, effectful\nupdate, and sealed-type narrowing — built on typed method references, records, sealed types, and a runtime-or-codegen\nduality. On the band they share (deep record↔record / bean↔record / bean↔bean), telescope matches MapStruct's codegen\nspeed with compile-checked, bidirectional, refactor-safe rows; beyond that band, telescope keeps going where MapStruct's\narchitecture stops. Two questions decide it — is it as fast, and what do you gain — in that order.\n\n#### First, the performance objection — settled\n\n**At the codegen level, telescope and MapStruct are the same performance class.** Both annotation processors emit direct\nconstructor and accessor calls the JIT inlines into one tight basic block. On the shape real services run — deeply\nnested records with lists inside — they are a **tie**: 1.15×, about 7 ns on a 47 ns conversion. On a trivial flat\n5-field struct MapStruct's hand-templated body is ~1.7 ns quicker (1.5×). At that scale you are choosing on **API and\ncapability, not nanoseconds.**\n\n| Tier (codegen vs codegen)       | telescope vs MapStruct                                                           |\n| ------------------------------- | -------------------------------------------------------------------------------- |\n| flat (5 scalars)                | 1.5–1.6× — ~1.7 ns absolute                                                      |\n| nested (one nested type)        | 1.6–2.0× — one shallow hop isolates framework overhead, not a real-service shape |\n| **deep (3 levels + list hops)** | **1.15× — a tie**                                                                |\n\nThe full CI-reproducible matrix — both directions, all three tiers, the runtime path, the methodology, and the\ngc-profiler decomposition — lives in\n[`benchmarks/README.md`](benchmarks/README.md#mapstruct-comparison-apples-to-apples). Reproduce any of it yourself via\nthe [`Benchmarks`](.github/workflows/benchmarks.yaml) GitHub Action: Actions → `Benchmarks` → `Run workflow`, pick a\nbranch, tune the iteration / fork knobs; the run prints `results.txt` and attaches the full results as an artifact.\n\n\u003e No codegen? `Telescope.mapper(...)` works reflectively with zero annotations — convenient for one-shot conversions and\n\u003e non-hot service code (single-microsecond on deep). On a tight inner loop, add `@Bridge` and you're back in MapStruct's\n\u003e performance class. The runtime-vs-codegen numbers are in\n\u003e [`benchmarks/README.md`](benchmarks/README.md#mapstruct-comparison-apples-to-apples).\n\n#### Then, what you gain\n\nThe table is mostly \"telescope: yes / MapStruct: not in scope\" — bidirectional from one definition, deep navigation,\neffectful update, accumulating validation, sealed-root dispatch, multi-source merge, JPA cycles + Hibernate `LAZY`\nunwrap. That asymmetry, not nanoseconds, is the decision.\n\n| Capability                                   | telescope                                                                                                                                                           | MapStruct                                                  |\n| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |\n| **Bidirectional out of the box**             | Every `Mapping.to(srcAcc, tgtAcc)` row works both ways via `Mapper.forward(...)` / `.backward(...)`                                                                 | One direction per `@Mapper` interface; reverse is separate |\n| **Deep nested navigation + update**          | `Telescope.of(C).each(C::depts).field(D::address).update(c, fn)`                                                                                                    | Not in scope                                               |\n| **Effectful update**                         | `updateAsync` / `updateOptional` / `updateEither` / `updateValidated`                                                                                               | Not in scope                                               |\n| **Accumulating validation**                  | `Validated.combine(...)` / `combineAll(...)` builds the target only when every field passes, collecting _all_ failures in one pass                                  | Throw on first bad field, or hand-rolled `@AfterMapping`   |\n| **Compile-time codegen**                     | `@Focus` / `@BeanFocus` / `@Bridge` annotation processors                                                                                                           | `@Mapper` interfaces                                       |\n| **Runtime path (no codegen required)**       | `Telescope.of(Class)` with reflective metadata probe; users can opt into `@Focus` later                                                                             | Compile-time only                                          |\n| **Sealed types / pattern matching**          | `.as(Subtype.class)` narrows; the path stays type-safe                                                                                                              | Not in scope                                               |\n| **Sealed-root dispatch**                     | `Match.of(...).when(Case.class, ...).exhaustive()` — compile-checked permit list, lattice-routed via `Prism.downcast()`                                             | Not in scope                                               |\n| **Multi-source mappers (N → 1)**             | `Telescope.merge(Target.class, from(A::id, T::id), …)` returning `Mapper\u003cSources, T\u003e` with a class-keyed `Sources` bag                                              | Multi-source methods with `@Mapping(source = \"param.x\")`   |\n| **Forward-only mappers**                     | `Telescope.mapperForward(...)` returning typed `ForwardMapper\u003cA, B\u003e` — no `backward` method at the type level                                                       | Write a separate `@Mapper` interface                       |\n| **Enum value mapping**                       | `Mapping.enumTo(src, tgt, SrcEnum.class, TgtEnum.class)` with build-time exhaustiveness                                                                             | `@ValueMapping(source = \"X\", target = \"Y\")`                |\n| **Null-coalescing defaults**                 | `Mapping.toOrElse(src, tgt, default)` / `toOrElseGet(src, tgt, supplier)` (predicate-gated overload)                                                                | `@Mapping(defaultValue = \"...\")` / `defaultExpression`     |\n| **Conditional / drop**                       | `Mapping.drop(src)` skips the field; predicate-gated `toOrElse(src, tgt, Predicate, default)` for value-conditional fallback                                        | `@Mapping(condition = \"...\")`                              |\n| **`@BeforeMapping` / `@AfterMapping` hooks** | `Mapper.beforeForward(...)` / `afterForward(...)` / `beforeBackward(...)` / `afterBackward(...)` — chain composes left-to-right                                     | Annotation-driven                                          |\n| **Spring / Quarkus / CDI integration**       | `telescope-spring-boot-starter` (Spring Boot 4 autoconfig + `Mapper\u003cA, B\u003e` bean registry) + `telescope-quarkus` (Arc extension, Jandex-discovered)                  | Native via `componentModel = \"spring\"` / `\"jsr330\"` / etc. |\n| **Maturity**                                 | 1.0 line; JMH-backed perf claims                                                                                                                                    | Ten years; thousands of production deployments             |\n| **Dispatch perf — codegen vs codegen**       | **Same performance class — a tie at realistic depth** (1.15× deep, 1.5× on a trivial flat struct); both emit direct JIT-inlined calls. CI-reproducible matrix below | Direct bytecode, monomorphic call site                     |\n\n#### Accumulating validation — what MapStruct can't say\n\nMapping a stringly-typed input into a typed domain object usually means validating several fields at once. MapStruct\nmaps field-by-field with no way to _collect_ failures — you throw on the first bad field or hand-roll an `@AfterMapping`\naccumulator. Telescope ships `Validated` as a first-class effect, so \"build the target only if every field passes, and\nreport all failures in one pass\" is a primitive:\n\n```java\n// Bad email AND bad age surface together — not just the first.\nfinal Validated\u003cString, Account\u003e account = Validated.combine(\n  validateEmail(form.email()),\n  validateAge(form.ageText()),\n  Account::new\n);\n\n// → Invalid[email: missing '@' …, age: out of range: 200]\n\n// combineAll folds a batch into one result — every error from every offending row:\nfinal Validated\u003cString, List\u003cAccount\u003e\u003e batch = Validated.combineAll(rows.stream().map(this::mapForm).toList());\n```\n\n`combine` accumulates (applicative); `Either` short-circuits on the first failure. For 3+ fields, chain `combine`.\nRunnable in\n[`ValidatedMappingDemo`](examples/library/src/main/java/io/github/eschizoid/telescope/examples/ValidatedMappingDemo.java).\n\n#### Per-field source/target mapping — side by side\n\nThe bread-and-butter MapStruct call — `@Mapping(source=\"x\", target=\"y\")` — has a direct telescope equivalent. The two\nlook alike on purpose; the differences are where the safety lives.\n\n```java\n// MapStruct\n@Mapper\npublic interface OrderMapper {\n  @Mapping(source = \"customerName\", target = \"fullName\")\n  @Mapping(source = \"createdAt\", target = \"createdDate\")\n  OrderDto toDto(Order order);\n}\n```\n\n```java\n// telescope — varargs factory\nfinal var mapper = Telescope.mapper(\n  Order.class,\n  OrderDto.class,\n  Mapping.to(Order::getCustomerName, OrderDto::getFullName),\n  Mapping.to(Order::getCreatedAt, OrderDto::getCreatedDate)\n);\n\n// Same-named fields backfill automatically — recursion is auto by default, no explicit row needed.\n\nfinal OrderDto dto = mapper.forward(order);\n\nfinal Order back = mapper.backward(dto);\n```\n\n`Telescope.map(...)` is the sibling that returns a `Telescope\u003cA, B\u003e` instead of a `Mapper\u003cA, B\u003e` — same factory shape,\nsame row vocabulary, useful when you want to thread the conversion into a longer `.then(...)` chain rather than call\n`forward` / `backward` / `patch` on a Mapper handle.\n\n| Aspect                               | MapStruct                                               | telescope                                                                  |\n| ------------------------------------ | ------------------------------------------------------- | -------------------------------------------------------------------------- |\n| **Source / target syntax**           | Strings: `\"customerName\"`                               | Typed method references: `Order::getCustomerName`                          |\n| **Typo / type-mismatch caught at**   | Annotation-processor run                                | **`javac` compile time** — the wrong-type accessor doesn't compile         |\n| **Survives a rename (IDE refactor)** | String breaks; processor re-runs and surfaces the error | IDE refactor follows the accessor everywhere                               |\n| **Reverse direction**                | A second method with `@InheritInverseConfiguration`     | Same `Mapping.to(...)` row works both ways                                 |\n| **Nested path** (`source = \"a.b.c\"`) | Expression-string                                       | `Mapping.via(srcAcc, tgtAcc, nestedMapper)` — typed at every hop           |\n| **Custom expression**                | `@Mapping(expression = \"java(...)\")`                    | `Mapping.via(srcAcc, tgtAcc, customMapper)` — plain Java mapper, type-safe |\n| **`condition = \"...\"` predicate**    | Annotation attribute                                    | `Edit.overIfPresent(...)` for updates; `Mapping.drop(...)` for mappings    |\n\nThe intent is identical; the calculus is different. MapStruct trades the typed-ref ergonomics for the ability to express\nthings like `source = \"user.address.street\"` as a single string. Telescope trades the string-path brevity for the\nguarantee that everything you wrote against the source/target types compiles iff it still makes sense.\n\n#### When MapStruct is the right pick\n\n- You need embedded expression-language mapping bodies — `@Mapping(expression = \"java(...)\")` or\n  `@Mapping(qualifiedByName = \"...\")` qualifier dispatch — and want them inline in the annotation rather than as plain\n  Java mappers passed to `Mapping.via(...)`\n- You need MapStruct-specific declarative shapes telescope doesn't expose: `@InheritConfiguration` row-set reuse, full\n  `@SubclassMapping` polymorphic dispatch, or `@MappingTarget` update-in-place semantics (telescope's `Mapper.patch`\n  covers sparse overlay, not full update-into-existing)\n- The mappers are flat `Entity → Dto` only — no bidirectional, deep navigation, sealed dispatch, multi-source merge, or\n  effectful update needs — and you'd never reach for optics for anything else\n\n#### When telescope is the right pick\n\n- Your problem includes **deep navigation** alongside mapping —\n  `Telescope.of(Company.class).each(Company::departments).field(Department::address).update(c, fn)` — and you don't want\n  a separate mapper for every level\n- You need **bidirectional** out of one definition — `Mapper.forward(...)` and `.backward(...)` derive from the same row\n  list, no inverse interface to write\n- You need to lift a mapping (or a field update) through an **effect** — `updateValidated`, `updateAsync`,\n  `updateOptional`, `updateEither`\n- You have **multi-source mappers** (`N → 1`) — `Telescope.merge(Target.class, from(A::id, T::id), …)` returns a\n  `Mapper\u003cSources, T\u003e` with a class-keyed bag; declared once, reusable\n- You have a **sealed root** to dispatch on — `Match.of(animal).when(Dog.class, …).when(Cat.class, …).exhaustive()`\n  gives compile-checked exhaustiveness over the permit list (and the `@Bridge` codegen emits exactly this for sealed\n  source types)\n- You're navigating a mix of **records and POJOs** at any depth and don't want to materialize intermediate DTOs to\n  bridge between them\n- You want the same `Telescope\u003cS, A\u003e` type to do reading, updating, mapping, and conversion — one mental model instead\n  of separate libraries\n\n---\n\n## Additional artifacts\n\nPublished to Maven Central under `io.github.eschizoid`. The six artifacts in the family:\n\n| Artifact                        | Role                                                                                                                                                                |\n| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `telescope-core`                | The DSL — `Telescope`, `Mapper`, `Mapping`, `Either` / `Validated`, annotations. The one you add for the runtime path.                                              |\n| `telescope-internal`            | Optic lattice + reflection helpers. Transitive only — pulled in automatically; users cannot reference it (JPMS qualified exports block visibility at compile time). |\n| `telescope-codegen`             | Optional `@Focus` / `@BeanFocus` / `@Bridge` annotation processor — see [Compile-time field navigation](#compile-time-reflection-free-navigation-focus--beanfocus). |\n| `telescope-lombok`              | Lombok-aware variant of the processor for `@Data` / `@Value` / `@Builder` POJOs.                                                                                    |\n| `telescope-spring-boot-starter` | Spring Boot 4 autoconfig + `Mapper\u003cA, B\u003e` bean registry.                                                                                                            |\n| `telescope-quarkus`             | Quarkus 3 CDI extension with the same registry shape.                                                                                                               |\n\n### Compile-time `@Focus` codegen (optional)\n\nAdd the processor only if you use the `@Focus` path. It's inert otherwise — the annotation is source-retention.\n\nGradle (Kotlin DSL):\n\n```kotlin\ndependencies {\n    implementation(\"io.github.eschizoid:telescope-core:1.0.5\")\n    annotationProcessor(\"io.github.eschizoid:telescope-codegen:1.0.5\")\n}\n```\n\nMaven:\n\n```xml\n\u003cdependency\u003e\n  \u003cgroupId\u003eio.github.eschizoid\u003c/groupId\u003e\n  \u003cartifactId\u003etelescope-core\u003c/artifactId\u003e\n  \u003cversion\u003e1.0.5\u003c/version\u003e\n\u003c/dependency\u003e\n\n\u003cbuild\u003e\n  \u003cplugins\u003e\n    \u003cplugin\u003e\n      \u003cgroupId\u003eorg.apache.maven.plugins\u003c/groupId\u003e\n      \u003cartifactId\u003emaven-compiler-plugin\u003c/artifactId\u003e\n      \u003cconfiguration\u003e\n        \u003cannotationProcessorPaths\u003e\n          \u003cpath\u003e\n            \u003cgroupId\u003eio.github.eschizoid\u003c/groupId\u003e\n            \u003cartifactId\u003etelescope-codegen\u003c/artifactId\u003e\n            \u003cversion\u003e1.0.5\u003c/version\u003e\n          \u003c/path\u003e\n        \u003c/annotationProcessorPaths\u003e\n      \u003c/configuration\u003e\n    \u003c/plugin\u003e\n  \u003c/plugins\u003e\n\u003c/build\u003e\n```\n\n#### Annotation processor ordering with Lombok\n\nWhen both Lombok and `telescope-lombok` / `telescope-codegen` sit on the annotation processor path, list **Lombok\nfirst**. Maven respects the declaration order of `\u003cannotationProcessorPaths\u003e`; Gradle respects the order of\n`annotationProcessor(...)` calls:\n\n```xml\n\u003cannotationProcessorPaths\u003e\n  \u003cpath\u003e\n    \u003cgroupId\u003eorg.projectlombok\u003c/groupId\u003e\n    \u003cartifactId\u003elombok\u003c/artifactId\u003e\n    \u003cversion\u003e1.18.30\u003c/version\u003e\n  \u003c/path\u003e\n  \u003cpath\u003e\n    \u003cgroupId\u003eio.github.eschizoid\u003c/groupId\u003e\n    \u003cartifactId\u003etelescope-lombok\u003c/artifactId\u003e\n    \u003cversion\u003e1.0.5\u003c/version\u003e\n  \u003c/path\u003e\n\u003c/annotationProcessorPaths\u003e\n```\n\n```kotlin\ndependencies {\n  annotationProcessor(\"org.projectlombok:lombok:1.18.30\")\n  annotationProcessor(\"io.github.eschizoid:telescope-lombok:1.0.5\")\n  annotationProcessor(\"io.github.eschizoid:telescope-codegen:1.0.5\")\n}\n```\n\nBoth `BridgeProcessor` and `LombokFocusProcessor` round-defer emission to `processingOver()` when they detect that the\nhost class (or its `@Bridge` target) carries a Lombok-synthesizing annotation, so the build is order-tolerant — but\nexplicit ordering avoids relying on round-deferral and is the recommended posture. The Lombok-synthesizing trigger set\nincludes `@Data`, `@Value`, `@Builder`, `@Getter`, `@Setter`, the three `*ArgsConstructor` variants, `@SuperBuilder`,\nand `@experimental.Accessors`.\n\nSymptoms of mis-ordering without round-deferral (now harmless thanks to the deferral fix, but worth recognizing on older\nversions): an emitted `\u003cX\u003eBridge` whose `forward`/`backward` are no-ops, or a `@Data` class for which no `\u003cX\u003eTelescope`\nlands. Both mean the telescope processor ran before Lombok patched the host class.\n\n### JPMS / modular consumers\n\nIf your project has a `module-info.java`, add the `requires` and, for the runtime navigation path, an `opens` for the\npackage containing your records / beans / POJOs:\n\n```java\nmodule com.acme.app {\n  requires io.github.eschizoid.telescope;\n\n  // Only needed if you use the RUNTIME path (Telescope.of, .ofBean, .map, .mapper).\n  // The codegen path (@Focus / @BeanFocus / @Bridge) needs no opens.\n  opens com.acme.model to io.github.eschizoid.telescope;\n}\n```\n\nThe `opens` target is **your** package — the one telescope needs to reach into — not telescope's. Runtime navigation\nbinds accessors via `MethodHandles.privateLookupIn(yourClass, MethodHandles.lookup())` and feeds the handles to\n`LambdaMetafactory` for hot-path dispatch. Without an `opens`, the lookup fails with `IllegalAccessException`, surfaced\nas:\n\n\u003e `Cannot access \u003cYourClass\u003e ... to build LambdaMetafactory \u003ckind\u003e. Add 'opens \u003cpkg\u003e to io.github.eschizoid.telescope;' to that module's module-info.java.`\n\nCopy the package from the error message into the `opens` directive.\n\n`telescope-internal` comes in transitively via `telescope-core`'s module declaration, but its packages are\nqualified-exported to `telescope-core` only, so you cannot accidentally reference internal lattice types from your own\ncode. `telescope-codegen` is compile-time-only and isn't on the runtime module path.\n\n**Codegen escape hatch.** The `@Focus` / `@BeanFocus` / `@Bridge` processors emit compile-time navigators that read\ncomponents and call constructors / builders / setters directly — no `privateLookupIn`, no `LambdaMetafactory`, no\n`opens` requirement. If adding the `opens` is awkward (e.g., a downstream module you don't own), the codegen path\nsidesteps the JPMS constraint entirely. See\n[Compile-time, reflection-free navigation](#compile-time-reflection-free-navigation-focus--beanfocus).\n\n**Classpath users (no `module-info.java`).** No `opens` needed — the JVM grants unnamed-module access automatically.\nThis section is JPMS-only.\n\n---\n\n## The DSL surface\n\nA single class, `Telescope\u003cS, A\u003e`, where `S` is the root type and `A` is the leaf you focus on. The full method\ninventory lives here as a reference; pick what you need by what you're trying to do, not by reading top-to-bottom.\n\n### Build\n\n| Method                                                             | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `Telescope.of(Class\u003cS\u003e)`                                           | Start at the root type.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `Telescope.lens(getter, setter)`                                   | Build a single-focus telescope directly, no reflection. Used by `@Focus` codegen; handy for hot paths.                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `Telescope.from(A).to(B).using(fwd, back)`                         | Build a `Telescope\u003cA, B\u003e` backed by an `Iso` — bidirectional type conversion that composes into longer paths.                                                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `Telescope.map(A.class, B.class, MapStep...)`                      | **Recommended.** Deep recursive mapping for any combination of records and POJOs (record↔record, POJO↔POJO, cross-paradigm at any depth). Same-name components identity-map, nested records/beans recurse, `List`/`Set`/`Map`/`Optional` lift the inner Iso through the container automatically. Override rows (`Mapping.to`, `Mapping.via`) and write-strategy hints (`WriteHint.writeBean(target, strategy)`) apply at any depth where their type pair appears. Sibling `Telescope.mapper(...)` returns `Mapper\u003cA, B\u003e`.                         |\n| `Telescope.ofBean(Class\u003cP\u003e)`                                       | Start a native POJO telescope — `.field`/`.each` navigate the bean directly, rebuilding via strategy (see [Working with POJOs](#working-with-pojos)).                                                                                                                                                                                                                                                                                                                                                                                             |\n| `.field(Class::accessor)`                                          | Descend into a record field via method reference. **Compile-checked.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `.fieldByName(String)`                                             | Descend by field name — the runtime escape hatch for late-binding (config-driven paths). **Runtime-checked:** wrong name → runtime error.                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `.fieldByName(String, Class\u003cB\u003e)`                                   | Same as above with an inline type witness for cleaner `var` inference. The `Class\u003cB\u003e` is inference sugar, **not validated** against the actual field.                                                                                                                                                                                                                                                                                                                                                                                             |\n| `.each(Class::collectionAccessor)`                                 | Descend into a `List`/`Set`/`Iterable` field and broadcast over elements. Element type inferred from the method ref. **Compile-checked.**                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `.list(Class::accessor)` / `.setField` / `.mapField` / `.optional` | Typed-container variants: keep the container type for later traversal. Return `ListTelescope\u003cS, X\u003e` / `SetTelescope\u003cS, X\u003e` / `MapTelescope\u003cS, K, V\u003e` / `OptionalTelescope\u003cS, X\u003e` — sealed subclasses of `Telescope` whose typed terminal (`.each()` / `.values()` / `.present()`) descends into elements via pure lattice composition. **Compile-checked, no runtime dispatch.** `setField` / `mapField` (1.0 rename) disambiguate from the write terminal `set(S, A)` and the static deep-conversion factory `Telescope.map(Class, Class, ...)`. |\n| `Telescope.asList(path)` / `asSet` / `asMap` / `asOptional`        | Promote a pre-built `Telescope\u003cS, List\u003cX\u003e\u003e` (or `Set`/`Map`/`Optional`) into the typed subclass so the compile-checked terminal becomes available. Useful when composing path fragments.                                                                                                                                                                                                                                                                                                                                                          |\n| `.eachValue(Class::mapAccessor)`                                   | Like `each`, but for `Map` values (keys preserved).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `.whenPresent(Class::optionalAccessor)`                            | Like `each`, but for `Optional` — no-op if empty.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `.as(Class)`                                                       | Narrow to a sealed-type case. Non-matching values pass through.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `.filter(Predicate)`                                               | Restrict to elements matching the predicate.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `.then(otherTelescope)`                                            | Compose two telescopes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |\n\n### Read\n\n| Method         | Returns                                                                                                                                                                                                                                                                                      |\n| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `.read(S)`     | The first focused value. Throws if absent.                                                                                                                                                                                                                                                   |\n| `.find(S)`     | `Optional\u003cA\u003e` of the first focused value.                                                                                                                                                                                                                                                    |\n| `.toList(S)`   | `List\u003cA\u003e` of all focused values.                                                                                                                                                                                                                                                             |\n| `.count(S)`    | How many values are focused.                                                                                                                                                                                                                                                                 |\n| `.exists(S)`   | `true` if there's at least one.                                                                                                                                                                                                                                                              |\n| `.withIndex()` | Index-aware chainable view (`Telescope.WithIndex\u003cS, A\u003e`). Exposes `.update(S, BiFunction\u003cInteger, A, A\u003e)`, `.toList(S)` → `List\u003cIndexed\u003cA\u003e\u003e`, `.find(S)`, `.count(S)`, `.exists(S)` — the same operations as the parent, with each focused value paired with its 0-based traversal position. |\n\n### Write\n\n| Method                                         | Returns                                                                                                                                                |\n| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `.set(S, A)`                                   | New `S` with every focused value replaced by the given one.                                                                                            |\n| `.update(S, Function\u003cA, A\u003e)`                   | New `S` with every focused value transformed.                                                                                                          |\n| `.updateAsync(S, fn, Executor)`                | Bounded-concurrency async update; pass a fixed pool to cap concurrent invocations.                                                                     |\n| `.updateIndexed(S, BiFunction\u003cInteger, A, A\u003e)` | Transform every focused value with its 0-based position in traversal order.                                                                            |\n| `.toListIndexed(S)`                            | `List\u003cIndexed\u003cA\u003e\u003e` — every focused value paired with its position.                                                                                     |\n| `.update(Telescope\u003cS, X\u003e, Function\u003cX, X\u003e)`     | Accumulate an edit through a pre-built path; returns `Telescope\u003cS, S\u003e` carrying the running chain. See [Multi-edit](#multi-edit). **Compile-checked.** |\n| `.with(Function\u003cA, A\u003e)`                        | Accumulate an edit at the current focus (inline-path equivalent of `.update(path, fn)`); returns `Telescope\u003cS, S\u003e`. **Compile-checked.**               |\n| `.apply(S)`                                    | Run every accumulated `.update(path, fn)` / `.with(fn)` edit against the source, in insertion order. Returns a new `S`.                                |\n\nMulti-edit packing (static factories — see [Multi-edit](#multi-edit)):\n\n| Method                                       | Returns                                                                                                                           |\n| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `Telescope.all(Edit\u003cS\u003e...)`                  | Reusable `Telescope\u003cS, S\u003e` normalizer that runs every edit, in argument order, on `apply(s)`. **Compile-checked.**                |\n| `Edit.over(Telescope\u003cS, X\u003e, Function\u003cX, X\u003e)` | Pair a pre-built path with its per-leaf transformation. Static-import-friendly: `import static …Edit.over;`. **Compile-checked.** |\n\n---\n\n## Cookbook\n\n### A single field\n\n```java\nfinal Telescope\u003cUser, String\u003e name = Telescope.of(User.class).field(User::name);\n\nname.read(alice);                        // \"alice\"\nname.set(alice, \"Bob\");                  // User with name=\"Bob\"\nname.update(alice, String::toUpperCase); // User with name=\"ALICE\"\n```\n\n### Nested fields\n\n```java\nfinal Telescope\u003cUser, String\u003e city = Telescope.of(User.class)\n        .field(User::address)\n        .field(Address::city);\n\ncity.update(alice, String::toUpperCase);\n```\n\n### Every element of a collection inside a record\n\n```java\nfinal Telescope\u003cTeam, String\u003e userNames = Telescope.of(Team.class)\n        .each(Team::users)\n        .field(User::name);\n\nuserNames.update(team, String::toUpperCase);\nuserNames.toList(team);                       // List\u003cString\u003e\n```\n\n### Sealed-type case\n\n```java\nsealed interface Event permits Created, Updated, Deleted {}\nrecord Created(String id) implements Event {}\nrecord Updated(String id, String diff, int revision) implements Event {}\nrecord Deleted(String id) implements Event {}\n\nfinal Telescope\u003cEvent, String\u003e updatedDiff = Telescope.of(Event.class)\n        .as(Updated.class)\n        .field(Updated::diff);\n\nupdatedDiff.update(event, s -\u003e s + \"!\"); // no-op if not Updated\nupdatedDiff.find(event);                 // Optional\u003cString\u003e\n```\n\n### Optional field\n\n```java\nrecord Profile(String id, Optional\u003cString\u003e nickname) {}\n\nfinal Telescope\u003cProfile, String\u003e nick = Telescope.of(Profile.class).whenPresent(Profile::nickname);\n\nnick.update(profile, String::toUpperCase); // no-op if nickname is empty\n```\n\n### Map values\n\n```java\nrecord Index(Map\u003cString, Integer\u003e byKey) {}\n\nfinal Telescope\u003cIndex, Integer\u003e values = Telescope.of(Index.class).eachValue(Index::byKey);\n\nvalues.update(index, v -\u003e v * 10);\n```\n\n### Typed container leaves (pre-built fragments)\n\nWhen you want a path that ends _at_ the container (not at its elements), use the typed `.list(Class::accessor)` /\n`.setField(...)` / `.mapField(...)` / `.optional(...)` instance methods. They return narrower subclasses\n(`ListTelescope`, `SetTelescope`, `MapTelescope`, `OptionalTelescope`) whose typed terminal step (`.each()` /\n`.values()` / `.present()`) descends into elements with zero runtime container dispatch — pure lattice composition,\nfully compile-checked.\n\n```java\nrecord Box(List\u003cString\u003e tags) {}\n\n// Build the list-typed path once; descend on demand.\nfinal ListTelescope\u003cBox, String\u003e tags = Telescope.of(Box.class).list(Box::tags);\nfinal Telescope\u003cBox, String\u003e elements = tags.each(); // typed .each() — compile-checked\n\nelements.update(box, String::toUpperCase);\n\n// Set / Map / Optional follow the same shape.\nrecord Cart(Set\u003cItem\u003e items) {}\n\nfinal SetTelescope\u003cCart, Item\u003e items = Telescope.of(Cart.class).setField(Cart::items);\nitems.each().field(Item::sku).update(cart, String::toUpperCase);\n```\n\nFor pre-built paths from elsewhere — composed `Telescope.then(...)` fragments, return types of helper methods, etc. —\npromote them with `Telescope.asList(...)` / `.asSet(...)` / `.asMap(...)` / `.asOptional(...)` so the typed terminal\nbecomes available:\n\n```java\nfinal Telescope\u003cCompany, List\u003cDepartment\u003e\u003e raw = ...; // built somewhere else\nTelescope.asList(raw).each().field(Department::name).update(co, String::toLowerCase);\n```\n\n### Indexed traversal\n\nWhen a read or update depends on position, not just value, use the indexed forms. The index is the 0-based position in\ntraversal order (flat across nested `each` levels):\n\n```java\nfinal Telescope\u003cTeam, String\u003e members = Telescope.of(Team.class).each(Team::members);\n\nmembers.toListIndexed(team);                               // [Indexed[0, \"alice\"], Indexed[1, \"bob\"], ...]\nmembers.updateIndexed(team, (i, name) -\u003e i + \": \" + name); // \"0: alice\", \"1: bob\", ...\n```\n\n### Filter mid-path\n\n```java\nfinal Telescope\u003cCompany, String\u003e engineeringEmails = Telescope.of(Company.class)\n        .each(Company::departments)\n        .filter(d -\u003e \"Engineering\".equals(d.name()))\n        .each(Department::teams)\n        .each(Team::users)\n        .field(User::email);\n\nengineeringEmails.update(company, String::toLowerCase);\n// Engineering emails lowercased; Sales untouched.\n```\n\n### Sealed-case + collection\n\n```java\nrecord Stream(List\u003cEvent\u003e events) {}\n\nfinal Telescope\u003cStream, Integer\u003e bumpRevisions = Telescope.of(Stream.class)\n        .each(Stream::events)\n        .as(Updated.class)\n        .field(Updated::revision);\n\nbumpRevisions.update(stream, r -\u003e r + 1);\n// Created / Deleted events pass through unchanged.\n```\n\n### Sibling access\n\nA plain `update` lambda only sees the focused value. When the transform needs sibling fields (the focused price needs\nthe SKU; the focused user needs the team name), close over the source — it's already in scope, since you pass it as the\nfirst argument.\n\n```java\nrecord Team(String name, List\u003cUser\u003e users) {}\n\nrecord User(String name, String bio) {}\n\nstatic final Telescope\u003cTeam, User\u003e USERS = Telescope.of(Team.class).each(Team::users);\n\n// Set every user's bio to mention the team name. The lambda reads the sibling `team.name()`.\nfinal Team stamped = USERS.update(team, (user) -\u003e new User(user.name(), \"Member of \" + team.name()));\n```\n\nThis works for every variant — `updateAsync`, `updateEither`, `updateValidated`, `updateOptional` — because the root the\nlambda needs is the same value you already hold. If the source is an expression rather than a variable, hoist it to a\nlocal first (`final var team = fetchTeam();`) and close over that.\n\n### Multi-edit\n\nTo apply several edits at different paths in one go, declare each path once as a static final, then pack the edits with\n`Telescope.all(over(...), over(...))`. Every step is fully compile-checked.\n\n**Recommended form — `Telescope.all(over(...), over(...))`.** Each `over(PATH, fn)` is one edit; `Telescope.all(...)`\nfolds them into a reusable `Telescope\u003cS, S\u003e` whose `.apply(s)` runs every edit in argument order.\n\n```java\nimport static io.github.eschizoid.telescope.Edit.over;\n\nstatic final Telescope\u003cCompany, String\u003e EMAILS = Telescope.of(Company.class)\n  .each(Company::departments)\n  .each(Department::teams)\n  .each(Team::users)\n  .field(User::email);\n\nstatic final Telescope\u003cCompany, String\u003e DEPT_NAMES = Telescope.of(Company.class)\n  .each(Company::departments)\n  .field(Department::name);\n\nstatic final Telescope\u003cCompany, String\u003e USER_NAMES = Telescope.of(Company.class)\n  .each(Company::departments)\n  .each(Department::teams)\n  .each(Team::users)\n  .field(User::name);\n\nfinal Telescope\u003cCompany, Company\u003e normalize = Telescope.all(\n  over(EMAILS,     String::toLowerCase),\n  over(DEPT_NAMES, String::trim),\n  over(USER_NAMES, titleCase));\n\nfinal Company done = normalize.apply(company);\nnormalize.apply(companyB);   // reusable across sources\n```\n\n`over(path, fn)` ties a `Telescope\u003cS, X\u003e` to a `Function\u003cX, X\u003e`; `javac` enforces the leaf type match. Each edit lives\non its own line, the count is visible at a glance, and there is no chain-blur between paths.\n\n**Single-edit shortcut.** For one edit, just call `update` on the path:\n\n```java\nEMAILS.update(company, String::toLowerCase);\n```\n\n**Chain accumulator (alternative).** The same semantics as `Telescope.all(...)` are also available as a fluent chain via\n`.update(path, fn)` and `.with(fn)` terminated by `.apply(source)` — useful when you want an inline path mid-chain\nwithout naming it. The chain reads less clearly for multiple distinct paths (the navigation segments visually blur), so\nprefer `Telescope.all(over(...))` when packing two or more edits.\n\n```java\n// Equivalent to the Telescope.all(...) form above:\nTelescope.of(Company.class)\n  .update(EMAILS,     String::toLowerCase)\n  .update(DEPT_NAMES, String::trim)\n  .update(USER_NAMES, titleCase)\n  .apply(company);\n\n// Inline one-shot trailing edit on a pre-built chain:\nTelescope.of(Company.class)\n  .update(EMAILS, String::toLowerCase)\n  .each(Company::departments).field(Department::name).with(String::trim)\n  .apply(company);\n```\n\nEdits run sequentially in argument / insertion order; the second sees the first's result, not the original source. An\nempty `Telescope.all()` (or an unedited chain) returns the source unchanged from `.apply(...)`.\n\n---\n\n## Type conversion\n\nTwo records that represent the same data (`Entity ↔ Dto`) convert through a bidirectional `Iso` that composes into\nlonger paths like any other telescope.\n\n### Hand-written (`from / to / using`)\n\nWrite the two conversion functions yourself; telescope doesn't auto-map (that's MapStruct's territory). What's different\nis that the conversion becomes a value, so it threads into longer paths.\n\n```java\nfinal Telescope\u003cUserEntity, UserDto\u003e userIso = Telescope.from(UserEntity.class)\n  .to(UserDto.class)\n  .using((e) -\u003e new UserDto(e.id(), e.email(), e.name()), (d) -\u003e new UserEntity(d.id(), d.email(), d.name()));\n\nUserDto dto = userIso.read(entity); // forward\n\nUserEntity updated = userIso.update(entity, (d) -\u003e new UserDto(d.id(), d.email().toLowerCase(), d.name()));\n//                                                                                              ↑ round-trips through DTO, returns Entity\n```\n\nThe conversion is an `Iso`, which means it composes into longer paths:\n\n```java\nrecord EntityPage(List\u003cUserEntity\u003e items, int total) {}\n\n// Walk into the page, view each entity as a DTO, focus the email, lowercase it.\n// Result is an EntityPage with UserEntity items — entities modified by round-tripping through DTO.\nTelescope.of(EntityPage.class)\n        .each(EntityPage::items)\n        .then(userIso)                         // ← Iso participates in the lattice\n        .field(UserDto::email)\n        .update(page, String::toLowerCase);\n```\n\n### Deep recursive mapping (`Telescope.map(A.class, B.class, to(...)...)`)\n\nThe recommended shape for record-to-record (and POJO↔POJO, and cross-paradigm) conversion: pass the source and target\nclasses up front, then varargs of `MapStep` rows. **Recursion is the default.** Same-named components identity-map,\nnested records / POJOs recurse, `List\u003cX\u003e↔List\u003cY\u003e` / `Set\u003cX\u003e↔Set\u003cY\u003e` / `Map\u003cK, X\u003e↔Map\u003cK, Y\u003e` / `Optional\u003cX\u003e↔Optional\u003cY\u003e`\nlift the inner-element Iso through the container automatically (to any depth — `List\u003cMap\u003cK, Set\u003cX\u003e\u003e\u003e` works by\nconstruction). You only spell the _differences_.\n\n```java\nimport static io.github.eschizoid.telescope.mapping.Mapping.to;\nimport static io.github.eschizoid.telescope.mapping.Mapping.via;\n\n// All same-name, no overrides — the pure-copy 1-liner:\nfinal Telescope\u003cUserEntity, UserDto\u003e userMapper = Telescope.map(UserEntity.class, UserDto.class);\n\n// Tree-deep mapping with two renames — every other field figures itself out:\nfinal Telescope\u003cCompanyEntity, CompanyDto\u003e companyMapper = Telescope.map(\n  CompanyEntity.class,\n  CompanyDto.class,\n  to(CompanyEntity::founded, CompanyDto::since), // top-level rename\n  to(UserEntity::name, UserDto::fullName)\n); // applies wherever User↔UserDto recurses\n```\n\nThe second example covers a 5-level structure — `Company → Department → Team → User → Address` — with `List`, `Map`, and\n`Optional` containers at multiple depths. Both renames are declared _once_; the `User::name → UserDto::fullName` rule\nfires _every time_ recursion encounters the `UserEntity ↔ UserDto` type pair (in `users[]`, in\n`department.head: Optional\u003cUser\u003e`, in `company.ceo: Optional\u003cUser\u003e` — all three at once).\n\n**How `to(...)` overrides are keyed.** Each `to(srcAccessor, tgtAccessor)` row carries its source and target record\nclasses implicitly via the method references. `Telescope.map(...)` reads them via `SerializedLambda` and uses\n`(sourceClass, targetClass)` as the key. When the recursion lands on a matching pair, the row's correspondence is\napplied; otherwise the recursion auto-resolves that component.\n\n**Cycle handling.** Self-referencing structures (a `User` that contains `Optional\u003cUser\u003e`) terminate naturally — the\nrecursion caches each type pair as it descends, and re-entry returns the in-progress entry instead of recursing forever.\n\n**Override forms.** Static-import-friendly factories on `Mapping`:\n\n| Factory                       | Purpose                                             | MapStruct equivalent                        |\n| ----------------------------- | --------------------------------------------------- | ------------------------------------------- |\n| `to(src, tgt)`                | Rename, same leaf type                              | `@Mapping(source, target)`                  |\n| `to(src, tgt, fwd, bwd)`      | Bidirectional typed transform                       | `@Mapping(source, target, qualifiedBy)`     |\n| `forward(src, tgt, fn)`       | Forward-only typed transform                        | (separate `@Mapper` interface)              |\n| `toOrElse(src, tgt, default)` | Null-coalesce to a default value                    | `@Mapping(defaultValue = \"...\")`            |\n| `toOrElseGet(src, tgt, sup)`  | Null-coalesce via a `Supplier`                      | `@Mapping(defaultExpression = \"java(…)\")`   |\n| `enumTo(src, tgt, SE, TE)`    | By-name enum mapping with build-time exhaustiveness | `@ValueMapping(source = \"X\", target = \"Y\")` |\n| `via(src, tgt, mapper)`       | Drop in a pre-built nested mapper                   | (composition by hand)                       |\n| `constant(tgt, value)`        | Forward-only literal at the target slot             | `@Mapping(constant = \"...\")`                |\n| `compute(tgt, supplier)`      | Forward-only supplier-computed value                | `@Mapping(expression = \"java(...)\")`        |\n| `drop(src)`                   | Skip the source field; backward zero-fills it       | `@Mapping(ignore = true)`                   |\n\nExample — three of those rows together:\n\n```java\nimport static io.github.eschizoid.telescope.mapping.Mapping.*;\n\nTelescope.mapper(\n  UserEntity.class,\n  UserDto.class,\n  to(UserEntity::name, UserDto::fullName),\n  toOrElse(UserEntity::region, UserDto::region, \"EMEA\"),\n  enumTo(UserEntity::status, UserDto::status, EntityStatus.class, DtoStatus.class)\n);\n```\n\nThe `via(...)` row works in two flavors: pass an **accessor-typed** mapper (e.g.,\n`Mapper\u003cList\u003cUserEntity\u003e, List\u003cUserDto\u003e\u003e`) and telescope uses it as-is, or pass an **element-typed** mapper\n(`Mapper\u003cUserEntity, UserDto\u003e`) and telescope detects the accessor's container shape (`List`, `Set`, `Optional`, `Map`\nvalues) and auto-lifts the mapper through it via `Iso.liftList` / `liftSet` / `liftOptional` / `liftMapValues`. One row\neither way — no separate `viaList` / `viaSet` factories.\n\nRecursion is auto by default — there's no `auto()` row to declare.\n\n**Result threads through longer paths** like any other telescope:\n\n```java\nTelescope.of(EntityPage.class)\n        .each(EntityPage::items)\n        .then(companyMapper)\n        .field(CompanyDto::name)\n        .update(page, String::toUpperCase);   // entities modified by round-tripping through the DTO\n```\n\n**`Telescope.mapper(A.class, B.class, ...)` — Mapper sibling.** Same factory, returns `Mapper\u003cA, B\u003e` instead of\n`Telescope\u003cA, B\u003e`. Same row syntax; same recursion. Useful for nested-mapper composition via `via(src, tgt, mapper)`.\n\nFor lossy or one-way conversions (dropping fields, non-invertible transforms), use `from/to/using` with hand-written\nfunctions. Telescope still won't auto-discover anything fuzzy — recursion only follows exact name matches plus the\nsame-shape container rule.\n\n---\n\n## Working with POJOs\n\nTelescope's deep-mapping factory handles any combination of records and POJOs through one entry point. The same\n`Telescope.map(A.class, B.class, ...)` call covers record↔record, POJO↔POJO, and the cross-paradigm record↔POJO mix at\nany depth — the engine picks per side whether to drive the canonical constructor (records) or `Beans.autoWriter` (POJOs)\nat every type pair the recursion encounters. The alternative is to navigate the POJO directly with\n`Telescope.ofBean(...)`. Either way updates are immutable.\n\n### Convert — `Telescope.map` / `Telescope.mapper`\n\nThe same factory described under [Type conversion](#type-conversion) handles POJO↔POJO and cross-paradigm record↔POJO\npairs without ceremony — components match by name on either side (`Pojo::getX` / `RecordOrPojo::x` normalized to `x`),\nnested POJOs recurse, and container hops auto-lift. The POJO mechanics this section covers are the bean-construction\nlever (`writeBean` / `writeBeans`) for when the auto-detect ladder can't pick a strategy.\n\n```java\nimport static io.github.eschizoid.telescope.mapping.Mapping.to;\nimport static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.SETTERS;\nimport static io.github.eschizoid.telescope.mapping.WriteHint.writeBeans;\n\nclass LegacyUser {\n  /* getId(), getEmail(), getName() + no-arg ctor + setters */\n}\n\nrecord UserRecord(String id, String email, String name) {}\n\n// Same-name 1-liner — every getter/component lines up by normalized name.\nfinal Telescope\u003cLegacyUser, UserRecord\u003e bridge = Telescope.map(LegacyUser.class, UserRecord.class);\n```\n\nRenames (`Mapping.to(srcAcc, tgtAcc)`), typed transforms (`Mapping.to(srcAcc, tgtAcc, fwd, bwd)`), null-coalescing\ndefaults (`Mapping.toOrElse` / `toOrElseGet`), by-name enum mapping (`Mapping.enumTo`), and pre-built nested mappers\n(`Mapping.via(srcAcc, tgtAcc, mapper)`) work the same way they do for records — see the rows under\n[Type conversion](#type-conversion).\n\n**`writeBean` — pin a POJO write strategy.** `Beans.autoWriter` picks a ladder: `builder()` → no-arg ctor + setters →\nno-arg ctor + reflective field injection → single public all-args ctor (when compiled with `-parameters` and ctor\nparameter names match the property names). For classes the auto path refuses (immutable all-args-only POJOs without\n`-parameters`, ambiguous multi-ctor classes), pass an explicit `WriteHint.writeBean(target, strategy)` row to force one\nof `BUILDER` / `SETTERS` / `FIELDS` / `CONSTRUCTOR`:\n\n```java\nimport static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.CONSTRUCTOR;\nimport static io.github.eschizoid.telescope.mapping.WriteHint.writeBean;\n\n// OrderPojo has a public (String sku, int qty) ctor, no builder, no setters — autoWriter would\n// refuse without -parameters. The hint forces the CONSTRUCTOR strategy explicitly.\nfinal Telescope\u003cOrderRecord, OrderPojo\u003e conv = Telescope.map(\n  OrderRecord.class,\n  OrderPojo.class,\n  writeBean(OrderPojo.class, CONSTRUCTOR),\n  to(OrderRecord::sku, OrderPojo::getSku)\n);\n```\n\nValidation is eager: a misconfigured hint (`BUILDER` on a no-builder class, hint targeting a record, duplicate hint,\nunused hint) throws at `Telescope.map(...)` time — not on first `iso.to()` deep in production.\n\n**`writeBeans(STRATEGY)` — one default for every bean target.** When every entity in the recursion shares the same\nconstruction shape (the common JPA case: every `@Entity` needs `SETTERS` so Hibernate's identity assignment fires), one\n`writeBeans(SETTERS)` row replaces N per-class enumerations. Per-class `writeBean(X.class, ...)` still wins for class\n`X`. At most one `writeBeans(...)` default per call.\n\n```java\nimport static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.SETTERS;\nimport static io.github.eschizoid.telescope.mapping.WriteHint.writeBean;\nimport static io.github.eschizoid.telescope.mapping.WriteHint.writeBeans;\n\nfinal Mapper\u003cOrder, OrderEntity\u003e orderMapper = Telescope.mapper(\n  Order.class,\n  OrderEntity.class,\n  writeBeans(SETTERS), // default for OrderEntity, CustomerEntity, LineItemEntity, AddressEmbeddable, …\n  writeBean(CashRegisterEntity.class, FIELDS) // override on one specific target\n);\n```\n\n**Composing through a bridge.** The mapping result is a `Telescope\u003cA, B\u003e`, so it threads through a longer path the same\nway any other telescope does:\n\n```java\nTelescope.of(Page.class)                  // Page is a record holding List\u003cLegacyUser\u003e\n    .each(Page::items)\n    .then(bridge)                         // each POJO ↔ record at this hop\n    .field(UserRecord::email)\n    .update(page, String::toLowerCase);\n```\n\n**`Telescope.mapper(...)` — the `Mapper\u003cA, B\u003e` sibling.** Same deep recursion, but the return is a `Mapper\u003cA, B\u003e`\nexposing `forward` / `backward` / `read` / `patch` / `asTelescope` / `liftList` / `liftSet` / `liftOptional` /\n`liftMapValues`. `patch(base, partial)` overlays non-null fields of `partial` onto `base` — useful for sparse JSON /\nform updates. `asTelescope()` returns the mapper as a `Telescope\u003cA, B\u003e` for `.then(...)` composition into a longer typed\npath (bridging record-side navigation into entity-side leaves, or vice versa). The `lift*` methods promote an\nelement-level mapper to a container-level mapper without going through a `via(...)` row — useful when the lifted mapper\nis the call-site root (e.g., a bulk handler that converts a `List\u003cOrder\u003e` payload to `List\u003cOrderEntity\u003e`).\n\n```java\nfinal Mapper\u003cUserBean, UserView\u003e mapper = Telescope.mapper(UserBean.class, UserView.class);\n\nfinal UserView withFresh = mapper.patch(view, new UserView(null, \"new@x\", null));\n\n// Container promotion for a bulk endpoint:\nfinal Mapper\u003cList\u003cUserBean\u003e, List\u003cUserView\u003e\u003e bulk = mapper.liftList();\nfinal List\u003cUserView\u003e view = bulk.forward(beans);\n\n// Thread the conversion into a longer Telescope chain via .then():\nTelescope.of(Page.class)\n    .each(Page::items)\n    .then(mapper.asTelescope())\n    .field(UserView::email)\n    .update(page, String::toLowerCase);\n```\n\nFor a worked end-to-end demo using every public Mapping / Mapper / Telescope row through a Spring Boot 4, Hibernate, and\nJackson REST pipeline, see [`examples/springboot/`](examples/springboot/).\n\n**`@Bridge` — reflection-free, compile-checked (any pair).** The codegen counterpart to `Telescope.map(...)`. Annotate\nthe source you own with the target type; the processor generates `\u003cSource\u003eBridge.BRIDGE`, a `Telescope\u003cSource, Target\u003e`\nbuilt from direct component/getter reads and constructor / builder / setter calls. Both sides may be records or POJOs —\nrecord⇄record, record⇄POJO, POJO⇄POJO. Fields match by name (a bijection); a name mismatch or a missing construction\nstrategy is a compile error, not a runtime one:\n\n```java\nimport io.github.eschizoid.telescope.annotations.Bridge;\n\n@Bridge(UserDto.class)\nrecord UserEntity(String id, String email) {}\n\n// Generated alongside:  UserEntityBridge.BRIDGE  (a Telescope\u003cUserEntity, UserDto\u003e)\nUserDto dto = UserEntityBridge.BRIDGE.read(entity);\n\n// BRIDGE is a Telescope value, so it threads through a longer path:\nfinal Page lowered = Telescope.of(Page.class)\n  .each(Page::entities) // each UserEntity on the page\n  .then(UserEntityBridge.BRIDGE) // view it as a UserDto\n  .field(UserDto::email)\n  .update(page, String::toLowerCase);\n```\n\nIt auto-detects each side's strategy at compile time (record canonical constructor; POJO name-matched constructor →\nbuilder → no-arg + setters). Renames and per-field transforms can't be expressed in an annotation — use the runtime\n`map` / `from/to/using` for those. Wire up `telescope-codegen` as shown under [Installation](#installation).\n\n**`from/to/using` — hand-written.** When the mapping is lossy, one-directional, or just custom, write both functions\nyourself:\n\n```java\npublic static final Telescope\u003cLegacyUser, UserRecord\u003e USER_BRIDGE = Telescope.from(LegacyUser.class)\n  .to(UserRecord.class)\n  .using(\n    (l) -\u003e new UserRecord(l.getName(), l.getEmail(), l.getAddress()),\n    (r) -\u003e {\n      final var u = new LegacyUser();\n      u.setName(r.name());\n      u.setEmail(r.email());\n      u.setAddress(r.address());\n      return u;\n    }\n  );\n```\n\n### Navigate — `ofBean`\n\nWhen you'd rather not define a mirror record, navigate the POJO directly. `.field(Pojo::getX)` reads via the getter;\n`set`/`update` rebuild the POJO immutably with that one property changed (write strategy auto-detected per type: builder\n→ setters → field injection). Deep paths and `.each(...)` compose like records:\n\n```java\nTelescope.ofBean(LegacyUser.class)\n  .field(LegacyUser::getAddress)\n  .field(Address::getCity)\n  .update(user, String::toUpperCase); // new LegacyUser; the original is untouched\n```\n\n**Cost — measured.** `ofBean` rebuilds the whole POJO and re-reads every getter at _each_ level of the path: a 3-level\nupdate benchmarks at ~442 ns/op (~18x a hand-written copy, ~1.8x record reflection — see\n[`benchmarks/`](benchmarks/README.md)). Fine for ordinary use (sub-microsecond); for a hot loop over many objects,\nconvert to a record once with `Telescope.map(Pojo.class, Record.class)` and navigate the record (or use `@BeanFocus`\ncodegen) instead. The runtime deep-mapping bridges are cheaper — ~114 ns (POJO→record) and ~142 ns (POJO↔POJO), in line\nwith the record→record mapper (~112 ns).\n\n**Aliasing — beans aren't records.** An update rebuilds the _spine_ (the path to the changed field) with fresh objects\nand shares references to untouched subtrees. With records that's always safe; with mutable POJOs the new and old object\nshare the same off-path sub-POJO instances, so mutating a shared sub-object afterward shows through both. Treat the\nshared parts as effectively immutable.\n\n### Scope\n\n`Telescope.map(...)` / `@Bridge` match by exact name and need a same-named field on each side (with optional rename rows\nvia `Mapping.to(srcAcc, tgtAcc)`); nested collections recurse automatically. The `FIELDS` write strategy (and `ofBean`'s\nfield-injection fallback) uses `setAccessible`, so under JPMS the POJO's package must be `opens`'d to\n`io.github.eschizoid.telescope` — `CONSTRUCTOR` / `BUILDER` / `SETTERS` (and all of `@Bridge`) use public members only.\n\n---\n\n## Compile-time, reflection-free navigation (`@Focus` / `@BeanFocus`)\n\nThe reflection-based `Telescope.of(User.class).field(User::name)` path resolves the field name at runtime — fast enough\nfor ordinary use (~100 ns), but a typo or a rename surfaces as a runtime error, not a compile error. Annotate the types\nyou navigate with `@Focus` (records) or `@BeanFocus` (POJOs) and add the processor to your build; for each annotated\ntype the processor emits a sibling **fluent typed path navigator** that reads like the runtime DSL but is fully\ncompile-checked and reflection-free.\n\n**Same path, two ways.** The two surfaces produce the same terminal `Telescope\u003cCompany, String\u003e` and the same `update`\nresult — they only differ in _when_ the path is resolved (runtime vs `javac`) and _how_ it's dispatched (reflection vs\ndirect method-ref + constructor calls). On the [benchmarks](benchmarks/README.md), the reflective deep-field path\nmeasures ~262 ns/op; the codegen lens path it desugars to measures ~45 ns/op (~5.8x).\n\n```java\n// Reflective — runtime resolution, ~100 ns per field hop\nTelescope.of(Company.class)\n  .each(Company::departments).each(Department::teams)\n  .each(Team::users).field(User::email)\n  .update(company, String::toLowerCase);\n\n// Compile-time, reflection-free — same Telescope, generator-built\nCompanyPath.of()\n  .departments().each().teams().each()\n  .users().each().email()\n  .update(company, String::toLowerCase);\n```\n\n```java\nimport io.github.eschizoid.telescope.annotations.Focus;\n\n@Focus record Address(String city, String zip) {}\n@Focus record User(String name, int age, Address address) {}\n@Focus record Team(String name, List\u003cUser\u003e users) {}\n@Focus record Company(String name, List\u003cTeam\u003e teams) {}\n\n// Generated: \u003cX\u003ePath\u003cR\u003e per annotated type plus a step class per collection-shaped component.\n// Usage reads like the reflective DSL — but every hop is type-checked by javac and every read /\n// rebuild is a direct method-ref + constructor call (no reflection):\nfinal Telescope\u003cCompany, String\u003e userNames = CompanyPath.of()\n  .teams().each()        // step over List\u003cTeam\u003e → TeamPath\u003cCompany\u003e\n  .users().each()        // step over List\u003cUser\u003e → UserPath\u003cCompany\u003e\n  .name();               // terminal Telescope\u003cCompany, String\u003e\n\nfinal Company shouted = userNames.update(company, String::toUpperCase);\n\n// Single fields are just as direct:\nUserPath.of().address().city().update(alice, String::toUpperCase);\n```\n\nEach scalar component yields a terminal `Telescope\u003cR, T\u003e`; each sub-record component (also `@Focus`-annotated) yields a\n`\u003cSub\u003ePath\u003cR\u003e` to keep navigating; each container component yields a small step class whose `.each()` (List/Set/\nIterable), `.eachValue()` (Map values, keys preserved), or `.whenPresent()` (Optional) returns the element's `Path` when\nthe element is itself annotated, or a terminal `Telescope` otherwise. At any hop, `.get()` returns the current\n`Telescope` — so a step or path _is_ a navigator, but every leaf is the same `Telescope\u003cR, X\u003e` value the reflective DSL\ngives you.\n\n**Ops at every hop, effects included.** Every generated `Path` and `Step` also forwards the full `Telescope` operation\nsurface — `read` / `find` / `toList` / `count` / `exists` / `set` / `update` / `updateIndexed` / `toListIndexed` /\n`then` plus the four effect methods `updateAsync` (with or without `Executor`) / `updateOptional` / `updateEither` /\n`updateValidated`. You don't need to terminate with `.get()` first; the navigator stands in for the wrapped Telescope at\nany intermediate hop. So `CompanyPath.of().teams().each().users().each().updateAsync(company, svc::lookup, pool)`\nreturns a `CompletableFuture\u003cCompany\u003e` directly, with the effect threaded through the generated chain.\n\n**Bridge hops — conversion as a navigator step.** If a type carries both `@Focus`/`@BeanFocus` (so it has a `*Path`) and\n`@Bridge(Target.class)` (so it has a `*Bridge.BRIDGE`), the navigator gains a fluent **`as\u003cTarget\u003e()`** method that\nchains the bridge in. The navigator becomes a single compile-checked surface for _both_ navigation _and_ conversion,\ncrossing paradigms naturally (record↔record, record↔POJO, POJO↔POJO):\n\n```java\n@Focus\n@Bridge(UserDto.class)\nrecord UserEntity(String id, String email) {}\n\n@Focus\nrecord UserDto(String id, String email) {}\n\n// Navigate through the bridge into a target field, then update. The Iso round-trips, so the\n// result is a new UserEntity:\nfinal UserEntity lowered = UserEntityPath.of()\n  .asUserDto() // → UserDtoPath\u003cUserEntity\u003e\n  .email() // → Telescope\u003cUserEntity, String\u003e\n  .update(entity, String::toLowerCase);\n```\n\nThe return type degrades to a terminal `Telescope\u003cR, Target\u003e` when the target isn't itself annotated (so there's no\n`\u003cTarget\u003ePath` to chain into). The reverse direction (target's Path getting `.asSource()`) still goes through\n`.then(SourceBridge.BRIDGE.reverse())` for now — forward only at the navigator level.\n\nGradle wiring:\n\n```kotlin\nimplementation(\"io.github.eschizoid:telescope-core:1.0.5\")\nannotationProcessor(\"io.github.eschizoid:telescope-codegen:1.0.5\")\n```\n\n`@Focus` and `@BeanFocus` are source-retention and inert without the processor, so annotating costs nothing if you don't\nwire up codegen. Only top-level records / classes are supported (the generated top-level navigator can't reference a\nnested type's constructor).\n\n**`@BeanFocus` — the POJO analog.** Same surface as `@Focus`, applied to a POJO with either a static `builder()` or a\nno-arg constructor + `setX` setters. Field injection isn't available to generated code, so a POJO that exposes neither\nis a compile error; reach for runtime `Telescope.ofBean` in that case. Compare ~488 ns for the runtime `ofBean` 3-level\npath vs ~15 ns for a generated `@Bridge` conversion in the benchmark — the navigator gets you the same reflection-free\nwin for navigation.\n\n```java\nimport io.github.eschizoid.telescope.annotations.BeanFocus;\n\n@BeanFocus public class UserBean { /* getId/getEmail + setters, or a static builder() */ }\n\n// Generated alongside: UserBeanPath\u003cR\u003e with the same fluent surface as a record navigator.\nUserBeanPath.of().email().update(user, String::toLowerCase);   // no reflection\n```\n\n---\n\n## Effects\n\nThe same path that powers `.update(...)` lifts through four effects with one method change: **async**,\n**all-or-nothing**, **short-circuit**, and **error-accumulating**. Validate every email in a `Batch` and report all the\nbad ones in one call? Two lines. Run an HTTP normalization call for every focused element with bounded concurrency? Pass\nan `Executor`. The DSL writes the structural plumbing; you supply the per-element function.\n\nPick the method by the function you have — the type system picks the applicative. Chaining stages of different effects\nis handled by the bridge methods on `Either` / `Validated`; see [Chaining stages](#chaining-stages).\n\n### Picking the method\n\n| Your function returns  | Call this              | You get back           | Semantics                        |\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------- |\n| `A → A` (pure)         | `update(...)`          | `S`                    | total, synchronous               |\n| `CompletableFuture\u003cA\u003e` | `updateAsync(...)`     | `CompletableFuture\u003cS\u003e` | sequence; any failure propagates |\n| `Optional\u003cA\u003e`          | `updateOptional(...)`  | `Optional\u003cS\u003e`          | any empty propagates             |\n| `Either\u003cE, A\u003e`         | `updateEither(...)`    | `Either\u003cE, S\u003e`         | short-circuit on first `Left`    |\n| `Validated\u003cE, A\u003e`      | `updateValidated(...)` | `Validated\u003cE, S\u003e`      | accumulate every error           |\n\n**Picking between `updateEither` and `updateValidated`:**\n\n- Use **`updateEither`** when failures should _halt work_: parsers where a malformed root makes children meaningless,\n  dependent stages, expensive per-element calls. Subsequent elements are never even called.\n- Use **`updateValidated`** when you want _every_ problem reported: form validation (show the user every wrong field at\n  once), batch quality reports, lightweight predicates over many elements. Every element is processed; failures are\n  collected.\n\nThe difference is control flow, not just result shape. You can't recover short-circuit behavior by post-converting a\nValidated result, and you can't recover all-errors reporting from an Either that stopped after the first failure.\n\n### The four effects, one at a time\n\nEach effectful method works on its own. Pick the one that matches the function you have. The examples below share this\ntiny domain:\n\n```java\nrecord Order(String id, String email) {}\n\nrecord Batch(List\u003cOrder\u003e orders) {}\n\n// Reusable path declared once, used by every example below.\nstatic final Telescope\u003cBatch, String\u003e ALL_EMAILS = Telescope.of(Batch.class).each(Batch::orders).field(Order::email);\n```\n\n**`updateAsync` — fan out, gather back.**\n\n```java\n// Hit an HTTP service to normalize every email in parallel. The future completes\n// when every per-element future has completed; failures propagate.\nfinal CompletableFuture\u003cBatch\u003e done = ALL_EMAILS.updateAsync(batch, normalizer::normalizeAsync);\n```\n\nThe path navigation, the per-element future creation, and the structural rebuild collapse into one method call. The\nnaive alternative — `stream().map(CompletableFuture::supplyAsync).collect(toList())` followed by\n`CompletableFuture.allOf(...)` followed by manual reconstruction of the `Batch` — is the boilerplate this replaces.\n\n**`updateValidated` — collect every error.**\n\n```java\nrecord EmailError(String email, String reason) {}\n\nfinal Validated\u003cEmailError, Batch\u003e result = ALL_EMAILS.updateValidated(batch, this::checkEmail);\n\nreturn result.fold(this::respondBadRequest, this::save);\n\n// The per-element predicate lives in a named method — easier to read, easier to test:\nprivate Validated\u003cEmailError, String\u003e checkEmail(final String email) {\n  if (!email.contains(\"@\")) return Validated.invalid(new EmailError(email, \"missing @\"));\n  return Validated.valid(email.toLowerCase());\n}\n```\n\nEvery bad email across the entire batch is reported, not just the first one. The applicative does the accumulation. The\nuser code never touches an error list directly.\n\n**`updateEither` — short-circuit on the first failure.**\n\n```java\nrecord ParseError(String input, String message) {}\n\nfinal Either\u003cParseError, Batch\u003e result = ALL_EMAILS.updateEither(batch, EmailParser::tryParse);\n\nreturn result.fold(this::respondError, this::save);\n```\n\nThe first email that fails to parse wins; later emails aren't even called. Use this when the first failure is enough —\nit's strictly cheaper than `updateValidated` because there's no accumulation.\n\n**`updateOptional` — all-or-nothing.**\n\n```java\n// If any single email fails to mask (returns Optional.empty), the whole batch becomes empty —\n// partial state is impossible.\nfinal Optional\u003cBatch\u003e masked = ALL_EMAILS.updateOptional(batch, this::tryMask);\n```\n\nThis is the right tool when a partially-updated structure would be a bug, not a feature.\n\n### Bounded async\n\nBy default `updateAsync` invokes `fn` synchronously per focused element; concurrency is whatever the futures returned by\n`fn` already had. To cap concurrent invocations, pass an `Executor`:\n\n```java\ntry (final var pool = Executors.newFixedThreadPool(10)) {  // ≤10 in-flight HTTP calls\n  final CompletableFuture\u003cBatch\u003e done = path.updateAsync(batch, this::fetchAsync, pool);\n  done.join();\n}\n```\n\n`fn` is wrapped in `CompletableFuture.supplyAsync(..., pool)`, so the executor bounds when `fn` is called. For fully\nnon-blocking `fn` (e.g. `HttpClient.sendAsync`) that's the right bound; for blocking work inside `fn`, the pool size is\nthe literal upper bound on in-flight operations.\n\n### Working with `Either` and `Validated`\n\n`Either\u003cL, R\u003e` and `Validated\u003cE, A\u003e` are sealed records shipped with the library, no Vavr/Arrow dependency. The typical\nhandler is `.fold(...)`:\n\n```java\nreturn parsed.fold(this::respondError, this::save);\n```\n\nPattern matching also works when you need to destructure the value, but Java's inference can't elide the type parameters\nin switch arms, so `.fold(...)` is usually less noisy:\n\n```java\nreturn switch (parsed) {\n  case Either.Right\u003cParseError, Company\u003e(var c) -\u003e save(c);\n  case Either.Left\u003cParseError, Company\u003e(var err) -\u003e respondError(err);\n};\n```\n\nBoth `Either` and `Validated` expose the same compact handler API:\n\n| Method                                           | Notes                                                                                               |\n| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |\n| `fold(onLeft, onRight)`                          | Collapse both sides into a single value. Usually what you want.                                     |\n| `map(f)`                                         | Transform the success side; failure passes through.                                                 |\n| `isLeft()` / `isRight()`                         | Boolean tests, when a `switch` would be overkill.                                                   |\n| `mapLeft(f)` (Either)                            | Transform the failure side; useful for normalizing error types at a boundary.                       |\n| `mapErrors(f)` (Validated)                       | Same idea as `mapLeft`, applied to every accumulated error.                                         |\n| `swap()` (Either)                                | Flip left and right.                                                                                |\n| `flatMap(f)` (Either)                            | Sequence two Eithers; short-circuits on the first `Left`.                                           |\n| `andThen(f)` (Validated)                         | Sequence two Validateds; short-circuits on `Invalid` (use `combine` to accumulate).                 |\n| `Validated.combine(a, b, f)` (Validated, static) | Combine two Validateds; accumulates errors across both branches.                                    |\n| `toValidated()` (Either)                         | Bridge to `Validated`: `Left(e)` becomes a single-element `Invalid([e])`.                           |\n| `toEither()` (Validated)                         | Bridge to `Either`: `Invalid(errs)` becomes `Left(errs)`.                                           |\n| `flatMapAsync(f)` (both)                         | Sequence an async stage; failures stay in the result, only success runs.                            |\n| `toOptional()` (both)                            | Drop the error and bridge to JDK `Optional`. Use when downstream only cares about the success path. |\n| `getOrElse(default)` (both)                      | Return the success value, or `default` on failure.                                                  |\n| `getOrElseGet(supplier)` (both)                  | Same, with a lazy default for expensive cases.                                                      |\n| `combineAll(List\u003c…\u003e)` (Validated, static)        | Combine a list of validations into a `Validated\u003cE, List\u003cA\u003e\u003e`; accumulates every error.              |\n\n### Chaining stages\n\nMulti-stage flows use the bridge methods on `Either` / `Validated` to keep the error channel consistent across different\neffects. The pattern is: normalize each stage's error type with `mapErrors` / `mapLeft`, bridge between accumulating and\nshort-circuiting with `toEither` / `toValidated`, then `flatMap` / `andThen` for sync stages or `flatMapAsync` when the\nnext stage returns a `CompletableFuture`.\n\nSync-only example — validate emails, then look up users, with one unified `List\u003cString\u003e` error channel:\n\n```java\n// Stage 1: collect every bad email, then hand off to short-circuit code\n// → Either\u003cList\u003cString\u003e, Batch\u003e\nfinal Either\u003cList\u003cString\u003e, Batch\u003e afterEmails = emailPath\n  .updateValidated(batch, this::checkEmail)\n  .mapErrors(EmailError::reason) // EmailError -\u003e String\n  .toEither(); // accumulating -\u003e short-circuit\n\n// Stage 2: short-circuit on the first user lookup failure, normalize its error too\n// → Either\u003cList\u003cString\u003e, Batch\u003e\nfinal Either\u003cList\u003cString\u003e, Batch\u003e afterUsers = afterEmails.flatMap((b) -\u003e\n  userPath.updateEither(b, this::lookupUser).mapLeft((err) -\u003e List.of(err.id() + \" not found\"))\n);\n```\n\nCrossing into an async stage uses `flatMapAsync`, which mirrors `flatMap` but accepts a function returning a\n`CompletableFuture`. Errors remain in the `Either` (or `Validated`) result; only the success side runs asynchronously:\n\n```java\nreturn afterUsers.flatMapAsync(ok -\u003e enrichPath.updateAsync(ok, this::enrich));\n// → CompletableFuture\u003cEither\u003cList\u003cString\u003e, Batch\u003e\u003e\n```\n\n---\n\n## Constraints worth knowing\n\n1. **Records only.** Field navigation rebuilds via the record's canonical constructor. Non-record types throw at runtime\n   with a clear message. To work with POJOs, bridge them to a record — see [Working with POJOs](#working-with-pojos).\n2. **Method references, not lambdas.** `User::name` works; `u -\u003e u.name()` doesn't. The compiler synthesizes a name like\n   `lambda$xx$0` and we can't recover the field name from it. The library throws a clear error.\n3. **`List\u003cT\u003e` element types are inferred from the method-ref signature**, not from runtime generics. That's why\n   `each(Team::users)` works without a type witness — `Team::users` has compile-time type `Function\u003cTeam, List\u003cUser\u003e\u003e`\n   and Java unifies `E = User`.\n4. **Reflection cost.** Field access uses `RecordComponent.getAccessor().invoke(...)` and the canonical constructor —\n   roughly ~100 ns per reflective field access, vs ~10 ns for a hand-written record copy; the reflection-free `lens`\n   path (`@Focus` codegen) sits in between. Fine for almost everything; matters for tight loops. See\n   [`benchmarks/`](benchmarks/README.md) for measured numbers.\n5. **Sibling-context updates close over the source.** A plain `update` lambda only sees the focused value. If you need\n   to read sibling fields (e.g., focus `LineItem::unitPrice` but want the sibling `sku` to call a price service), the\n   source is already in scope as the first argument — reference it inside the lambda\n   (`update(order, item -\u003e … order.sku() …)`). Hoist the source to a local first if it's an expression.\n6. **One documented runtime-check point on the runtime DSL.** Every typed entry point (`.field(Accessor)`,\n   `.each(Accessor)`, `.list(Accessor)` / `.set` / `.map` / `.optional` and their typed terminals,\n   `.eachValue(Accessor)`, `.whenPresent(Accessor)`, the static `Telescope.asList` / `asSet` / `asMap` / `asOptional`\n   promotions, the bridges, `.with(fn)`, `.apply(S)`, every `update*` variant) is fully compile-checked. One escape\n   hatch is _not_ compile-checked, by design, and it's named so the call site says so:\n   - `.fieldByName(String)` / `.fieldByName(String, Class\u003cB\u003e)` — late-bound field name (config-driven paths). `javac`\n     can't verify the name exists or that the inferred type matches the actual field. Wrong name → runtime error.\n\n   For zero runtime-check points, use the **`@Focus` / `@BeanFocus` / `@Bridge` annotation processors** — they generate\n   a typed `\u003cX\u003ePath\u003cR\u003e` navigator at compile time, with every step a typed method call.\n\n7. **Versioning policy — semver.** Source and binary compatibility across minor versions; breaks only on majors.\n\n---\n\n## Architecture (short version)\n\nThree modules with a hard public/internal boundary:\n\n- **`telescope-core`** — the public DSL. `Telescope\u003cS, A\u003e` plus the `Mapping` / `Mapper` / `Edit` / effects vocabulary\n  and the `@Focus` / `@BeanFocus` / `@Bridge` annotations.\n- **`telescope-internal`** — the optic lattice (`Iso`, `Lens`, `Prism`, `Affine`, `Traversal`, `Getter`, `Setter`,\n  `Fold`), `Kind` / `Applicative` HKT-emulation, and reflection helpers. Packages are qualified-exported\n  `to io.github.eschizoid.telescope` only via JPMS, so the lattice types never appear on your classpath at compile time.\n  The lattice is the substrate, not the API.\n- **`telescope-codegen`** — compile-time-only annotation processor. Not required on the runtime module path.\n\nEach DSL method builds the appropriate optic and composes it via the lattice — `Telescope.of(C.class)` is\n`Iso.identity()`, `.field(C::name)` is a `Records.fieldLens(name)` wrapped as `Lens\u003cC, X\u003e` and composed via\n`Traversal.then(Lens)`, `.each(C::items)` is two `.then` calls (one for the container `Lens`, one for the element\n`Traversal`), `.as(Updated.class)` is `Prism.downcast(Updated.class)` via `Traversal.then(Prism)`, and so on. Operations\n(`read`, `set`, `update`, `toList`, `count`, `exists`) delegate to the underlying optic's methods. Composition rules\n(`Lens.then(Prism) = Affine`, `Iso.then(Iso) = Iso`, etc.) and laws (get-set, set-get, set-set, iso round-trip, prism\npartial round-trip) live in the lattice and are pinned by `OpticLawsTest`.\n\nIf you ever want the optic types as public API (Monocle interop, or extending the library), flip the\n`exports … to io.github.eschizoid.telescope` lines in `telescope-internal`'s `module-info.java` to unqualified exports.\nThe types are already there; the JPMS export list is the gate.\n\n---\n\n## Build \u0026 test\n\n```bash\n./gradlew spotlessApply # format code\n./gradlew build         # compile, run tests\n```\n\nThe integration tests use Testcontainers and require a reachable Docker daemon. Linux and macOS Docker Desktop both work\nout of the box (Testcontainers 2.x autodetects the socket). Without a reachable daemon the integration tests are\nsilently skipped, not failed.\n\n---\n\n## License\n\nApache 2.0 — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feschizoid%2Ftelescope","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Feschizoid%2Ftelescope","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feschizoid%2Ftelescope/lists"}