Build a typed path through your nested data — then read, update, or convert through it. Bidirectionally. One line.
Works on Java records, POJOs, and Lombok @Data classes. Compile-time codegen is optional. Spring Boot starter and
Quarkus extension ship as separate artifacts.
Coming from MapStruct? This is the upgrade. MapStruct's architecture is a decade old — string-keyed @Mapping
annotations, compile-time-only, one direction per interface, mapping, and nothing else. Telescope does that same job at
the same codegen speed (a tie on the shape real services run — deep nesting with list traversals), but on a modern
foundation: typed method references the compiler checks (a typo is a javac error, not a processor warning),
bidirectional from a single declaration, runtime or codegen — and with telescope-codegen on the processor path,
every statically-visible mapper call site is verified complete at compile time, no annotation required. Then it
keeps going where MapStruct structurally stops — deep navigation, effectful update, sealed-root dispatch, multi-source
merge, JPA-cycle and Hibernate-LAZY handling, all from one Telescope<S, A> type. Same speed, compile safety
MapStruct can't give you, and a strictly larger surface. See it row by row →
// Gradle (Kotlin DSL)
dependencies {
implementation("io.github.eschizoid:telescope-core:1.1.1")
}<!-- Maven -->
<dependency>
<groupId>io.github.eschizoid</groupId>
<artifactId>telescope-core</artifactId>
<version>1.1.1</version>
</dependency>That's the runtime. Compile-time codegen, Spring Boot starter, Quarkus extension, and JPMS setup are listed below.
You have nested data and you want to update a field deep inside without writing copy constructors:
record Address(String city, String zip) {}
record User(String name, Address address) {}
// 1. Build a typed path once.
final var userCity = Telescope.of(User.class).field(User::address).field(Address::city);
// 2. Use it for reading, updating, anything else.
String city = userCity.read(alice); // → "Springfield"
User shouted = userCity.update(alice, String::toUpperCase); // → city becomes "SPRINGFIELD"That's the whole model. Every other capability — mapping between types, navigating containers, lifting through async/validation effects — is the same path with a different terminal method.
What's next:
- Navigate
List<X>/Optional<X>/Map<K, V>→ Cookbook - Convert between types (record↔record, POJO↔record) → Type conversion
- Lift through async / validated / either / optional effects → Effects
- Compile-time-bound navigators for hot paths → Compile-time codegen
Two questions decide it: are you working with records or POJOs, and do you want to navigate one type in place or convert between two types?
| You want to… | Records | POJOs | POJO ⇄ record |
|---|---|---|---|
| Navigate & update in place | Telescope.of(R.class) |
Telescope.ofBean(P.class) |
bridge first (below), then navigate the record |
| Convert / map between two types | Telescope.map(A.class, B.class, to(...), …) |
Telescope.map(A.class, B.class, …) |
Telescope.map(P.class, R.class, …) |
| Reflection-free (compile-checked) | @Focus (navigate) |
@BeanFocus (navigate) |
@Bridge (convert, any pair) |
Conversions are bidirectional Isos, so any cell in the middle row composes into a longer navigation path with
.then(...). Mismatched names get an explicit Mapping.to(srcAccessor, tgtAccessor) row in the Telescope.map(...)
call; classes the auto-detect can't handle get a WriteHint.writeBean(target, strategy) row. Both are covered under
Working with POJOs.
import io.github.eschizoid.telescope.Telescope;
record Address(String city, String zip) {}
record User(String name, int age, String email, Address address) {}
record Team(String name, List<User> users) {}
record Department(String name, List<Team> teams) {}
record Company(String name, List<Department> departments) {}One task — lowercase every user's email in the whole company tree — done both ways.
final Company lowered = new Company(
company.name(),
company
.departments()
.stream()
.map((d) ->
new Department(
d.name(),
d
.teams()
.stream()
.map((t) ->
new Team(
t.name(),
t
.users()
.stream()
.map((u) -> new User(u.name(), u.age(), u.email().toLowerCase(), u.address()))
.toList()
)
)
.toList()
)
)
.toList()
);final Telescope<Company, String> emails = Telescope.of(Company.class)
.each(Company::departments)
.each(Department::teams)
.each(Team::users)
.field(User::email);
final Company lowered = emails.update(company, String::toLowerCase);~25 lines of manual reconstruction — every constructor enumerated, every untouched field threaded through by hand — versus one reusable path. And the path isn't single-use:
emails.toList(company); // List<String> of every email
emails.count(company); // how manySame tree, now translate Company to a partner-facing CompanyDto with a few renamed fields — one definition, both
directions:
record AddressDto(String town, String postalCode) {}
record UserDto(String fullName, int age, String email, AddressDto address) {}
record TeamDto(String name, List<UserDto> users) {}
record DepartmentDto(String name, List<TeamDto> teams) {}
record CompanyDto(String name, List<DepartmentDto> departments) {}
final Mapper<Company, CompanyDto> dtoMapper = Telescope.mapper(
Company.class,
CompanyDto.class,
to(User::name, UserDto::fullName), // rename, applies everywhere User↔UserDto recurses
to(Address::city, AddressDto::town),
to(Address::zip, AddressDto::postalCode)
);
final CompanyDto dto = dtoMapper.forward(company);
final Company restored = dtoMapper.backward(dto); // ← bidirectional from one definitionSame-name fields auto-recurse (User::email, User::age, all the list/tree wiring). You only name what changes.
MapStruct needs a second @Mapper interface for the inverse direction; telescope does not.
Need a flat field to land at a nested target leaf — MapStruct's @Mapping(source = "flat", target = "a.b.c")? The
codegen-emitted navigator is a first-class argument to Mapping.to(...):
Telescope.mapper(Cart.class, CartDto.class,
to(Cart::customerName, CartDtoTelescope.of().shipping().recipient().fullName()));Every hop is typed; javac and the IDE refactor follow each step.
Need eager literals or per-call computed values stamped at the target — MapStruct's @Mapping(constant = "...") and
@Mapping(expression = "java(...)")? Declared in the same Telescope.mapper(...) call:
Telescope.mapper(Order.class, OrderDto.class,
to(Order::id, OrderDto::id),
constant(OrderDto::tenant, "production"), // eager literal
compute(OrderDto::createdAt, Instant::now), // fresh per call
compute(OrderDto::traceId, UUID::randomUUID),
compute(OrderDto::metadata, HashMap::new)); // fresh container per callconstant captures once at row construction; compute invokes the supplier each forward call (the right choice
whenever a literal would share one mutable reference — HashMap::new, Instant::now, UUID::randomUUID). Both are
forward-only by design; backward direction silently drops the slot, matching MapStruct semantics.
POJOs don't need a mirror record. Navigate the bean directly with ofBean; set/update rebuild it immutably, so the
original is never mutated:
class Address {
/* getCity()/setCity(), getZip()/setZip() */
}
class User {
/* getName(), getAddress() + setters */
}
final User moved = Telescope.ofBean(User.class)
.field(User::getAddress)
.field(Address::getCity)
.update(user, String::toUpperCase); // new User; `user` untouchedPrefer to stay in records? Convert a POJO with Telescope.map(Pojo.class, Record.class, ...) and navigate that — see
Working with POJOs.
That's the library. No Iso, Lens, Prism, Affine, Traversal, Getter, Setter, Fold in user-facing code.
Five runnable demos cover the surface — pick the one matching what you're evaluating:
| Module | Stack | Pick when |
|---|---|---|
examples/library/ |
plain Java, no framework | You want to see what the DSL does in isolation — 10 atomic capability demos (*Demo.java mains) |
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 |
examples/springboot/product-starter/ |
Spring Boot autoconfig | You want zero-wiring registry discovery — drop @Bean Mapper<A, B> declarations and the starter indexes them |
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 |
examples/springboot/invoicing/ |
@Bridge codegen |
You want zero-reflection compile-time-bound conversion on a hot path |
Where to start. If you're evaluating telescope and want the broadest view of what it can do, lead with
order-jpa/ — it's the kitchen sink. If you want to see telescope without any
framework wrapping it, browse examples/library/ first. The other three Spring Boot demos are
focused follow-ups for specific concerns.
See examples/springboot/README.md for the full per-module guide with endpoint maps,
capability lists, vs-MapStruct callouts, and benchmark cross-links.
-
Not bound to MapStruct's architecture. MapStruct is a decade-proven framework, but its design is string-keyed
@Mappingannotations, compile-time-only, one direction per interface, and mapping is the whole job. Telescope is typed optics: method-reference rows the compiler checks, bidirectional from one declaration, runtime or codegen, and mapping is one capability among navigation, deep update, effectful update, and sealed dispatch. The codegen path runs at the same performance class — a tie at real-service depth (see How it compares) — over a strictly larger, more modern surface. It covers every common@Mapping(...)shape — same-name auto, renames, typed transforms, nested mappers, flat → nested-path correspondences, eager literals, computed values, forward-only mappers, multi-source merge, by-name enum mapping, null-coalescing defaults, lifecycle hooks, Spring/Quarkus autoconfig. And it reaches the shapes MapStruct's architecture can't express. MapStruct still leads on raw maturity and a handful of declarative features (inlineexpression = "java(...)"bodies, qualifier dispatch, full@SubclassMapping,@MappingTargetupdate-in-place); When MapStruct is the right pick is honest about those. The full row-by-row comparison has the rest. -
Not a fuzzy auto-mapper.
Telescope.map(...)matches fields by exact name and type, nothing more — no fuzzy name heuristics, no flattening, no inferred relationships (that's ModelMapper / Dozer territory, and they lost to MapStruct for good reasons). Anything that isn't an exact name match you declare yourself with aMapping.to(srcAcc, tgtAcc)orMapping.via(srcAcc, tgtAcc, nestedMapper)row. -
Not category theory. Internally it's a Monocle-style Traversal, but
Iso,Lens,Prism,Affine, andTraversalare all package-private behind a JPMS boundary. You read, write, update, traverse, convert, and lift through oneTelescope<S, A>type — you never have to type the academic words.
Same job, newer architecture. MapStruct is a mapping framework built on string-keyed @Mapping annotations,
compile-time-only generation, and one direction per interface — purpose-built for flat Entity → Dto conversion.
Telescope is an optics DSL where that same mapping is one capability among navigation, deep updates, effectful
update, and sealed-type narrowing — built on typed method references, records, sealed types, and a runtime-or-codegen
duality. On the band they share (deep record↔record / bean↔record / bean↔bean), telescope matches MapStruct's codegen
speed with compile-checked, bidirectional, refactor-safe rows; beyond that band, telescope keeps going where MapStruct's
architecture stops. Two questions decide it — is it as fast, and what do you gain — in that order.
Runnable head-to-head:
examples/mapstruct-vs-telescopeis the canonical side-by-side — the sameOrder → OrderDtomapping written both ways, in one module. It demonstrates, reproducibly, what a field rename does to each (telescope's method reference follows the IDE refactor automatically; MapStruct's@Mappingstring can't be refactored, so the same rename is a compile error you hand-fix across every mapper), the separate default-policy footgun where unmapped targets go silentlynull, then a deep immutable update MapStruct's architecture can't express. Run./gradlew :examples:mapstruct-vs-telescope:test— every claim is a passing test or a one-command reproduction.
At the codegen level, telescope and MapStruct are the same performance class. Both annotation processors emit direct constructor and accessor calls the JIT inlines into one tight basic block. On the shape real services run — deeply nested records with lists inside — they are a tie: 1.15×, about 7 ns on a 47 ns conversion. On a trivial flat 5-field struct MapStruct's hand-templated body is ~1.7 ns quicker (1.5×). At that scale you are choosing on API and capability, not nanoseconds.
| Tier (codegen vs codegen) | telescope vs MapStruct |
|---|---|
| flat (5 scalars) | 1.5–1.6× — ~1.7 ns absolute |
| nested (one nested type) | 1.6–2.0× — one shallow hop isolates framework overhead, not a real-service shape |
| deep (3 levels + list hops) | 1.15× — a tie |
The full CI-reproducible matrix — both directions, all three tiers, the runtime path, the methodology, and the
dispatch-overhead decomposition — lives in
benchmarks/README.md. Reproduce any of it yourself via
the Benchmarks GitHub Action: Actions → Benchmarks → Run workflow, pick a
branch, tune the iteration / fork knobs; the run prints results.txt and attaches the full results as an artifact.
No codegen?
Telescope.mapper(...)works reflectively with zero annotations — convenient for one-shot conversions and non-hot service code (single-microsecond on deep). On a tight inner loop, add@Bridgeand you're back in MapStruct's performance class. The runtime-vs-codegen numbers are inbenchmarks/README.md.
The table is mostly "telescope: yes / MapStruct: not in scope" — bidirectional from one definition, deep navigation,
effectful update, accumulating validation, sealed-root dispatch, multi-source merge, JPA cycles + Hibernate LAZY
unwrap. That asymmetry, not nanoseconds, is the decision.
| Capability | telescope | MapStruct |
|---|---|---|
| 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 |
| Deep nested navigation + update | Telescope.of(C).each(C::depts).field(D::address).update(c, fn) |
Not in scope |
| Effectful update | updateAsync / updateOptional / updateEither / updateValidated |
Not in scope |
| 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 |
| Compile-time codegen | @Focus / @BeanFocus / @Bridge annotation processors |
@Mapper interfaces |
| Unmapped-target safety | Compile error at the call site — telescope-codegen verifies every statically-visible mapper(...) / map(...) call, on by default (knobs and scope in the telescope-codegen README); strict construction-time refusal is the always-on backstop |
unmappedTargetPolicy defaults to WARN — compiles, field is silently null at runtime; ERROR is opt-in |
| Runtime path (no codegen required) | Telescope.of(Class) with reflective metadata probe; users can opt into @Focus later |
Compile-time only |
| Sealed types / pattern matching | .as(Subtype.class) narrows; the path stays type-safe |
Not in scope |
| Sealed-root dispatch | Match.of(...).when(Case.class, ...).exhaustive() — compile-checked permit list, lattice-routed via Prism.downcast() |
Not in scope |
| Multi-source mappers (N → 1) | Telescope.merge(Target.class, from(A::id, T::id), …) returning Mapper<Sources, T> with a class-keyed Sources bag |
Multi-source methods with @Mapping(source = "param.x") |
| Forward-only mappers | Telescope.mapperForward(...) returning typed ForwardMapper<A, B> — no backward method at the type level |
Write a separate @Mapper interface |
| Enum value mapping | Mapping.enumTo(src, tgt, SrcEnum.class, TgtEnum.class) with build-time exhaustiveness |
@ValueMapping(source = "X", target = "Y") |
| Null-coalescing defaults | Mapping.toOrElse(src, tgt, default) / toOrElseGet(src, tgt, supplier) (predicate-gated overload) |
@Mapping(defaultValue = "...") / defaultExpression |
| Conditional / drop | Mapping.drop(src) skips the field; predicate-gated toOrElse(src, tgt, Predicate, default) for value-conditional fallback |
@Mapping(condition = "...") |
@BeforeMapping / @AfterMapping hooks |
Mapper.beforeForward(...) / afterForward(...) / beforeBackward(...) / afterBackward(...) — chain composes left-to-right |
Annotation-driven |
| Spring / Quarkus / CDI integration | telescope-spring-boot-starter (Spring Boot 4 autoconfig + Mapper<A, B> bean registry) + telescope-quarkus (Arc extension, Jandex-discovered) |
Native via componentModel = "spring" / "jsr330" / etc. |
| Maturity | 1.0 line; JMH-backed perf claims | Ten years; thousands of production deployments |
| 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 |
Mapping a stringly-typed input into a typed domain object usually means validating several fields at once. MapStruct
maps field-by-field with no way to collect failures — you throw on the first bad field or hand-roll an @AfterMapping
accumulator. Telescope ships Validated as a first-class effect, so "build the target only if every field passes, and
report all failures in one pass" is a primitive:
// Bad email AND bad age surface together — not just the first.
final Validated<String, Account> account = Validated.combine(
validateEmail(form.email()),
validateAge(form.ageText()),
Account::new
);
// → Invalid[email: missing '@' …, age: out of range: 200]
// combineAll folds a batch into one result — every error from every offending row:
final Validated<String, List<Account>> batch = Validated.combineAll(rows.stream().map(this::mapForm).toList());combine accumulates (applicative); Either short-circuits on the first failure. For 3+ fields, chain combine.
Runnable in
ValidatedMappingDemo.
The bread-and-butter MapStruct call — @Mapping(source="x", target="y") — has a direct telescope equivalent. The two
look alike on purpose; the differences are where the safety lives.
// MapStruct
@Mapper
public interface OrderMapper {
@Mapping(source = "customerName", target = "fullName")
@Mapping(source = "createdAt", target = "createdDate")
OrderDto toDto(Order order);
}// telescope — varargs factory
final var mapper = Telescope.mapper(
Order.class,
OrderDto.class,
Mapping.to(Order::getCustomerName, OrderDto::getFullName),
Mapping.to(Order::getCreatedAt, OrderDto::getCreatedDate)
);
// Same-named fields backfill automatically — recursion is auto by default, no explicit row needed.
final OrderDto dto = mapper.forward(order);
final Order back = mapper.backward(dto);Telescope.map(...) is the sibling that returns a Telescope<A, B> instead of a Mapper<A, B> — same factory shape,
same row vocabulary, useful when you want to thread the conversion into a longer .then(...) chain rather than call
forward / backward / patch on a Mapper handle.
| Aspect | MapStruct | telescope |
|---|---|---|
| Source / target syntax | Strings: "customerName" |
Typed method references: Order::getCustomerName |
| Typo / type-mismatch caught at | Annotation-processor run | javac compile time — the wrong-type accessor doesn't compile |
| Survives a rename (IDE refactor) | String breaks; processor re-runs and surfaces the error | IDE refactor follows the accessor everywhere |
| Reverse direction | A second method with @InheritInverseConfiguration |
Same Mapping.to(...) row works both ways |
Nested path (source = "a.b.c") |
Expression-string | Mapping.via(srcAcc, tgtAcc, nestedMapper) — typed at every hop |
| Custom expression | @Mapping(expression = "java(...)") |
Mapping.via(srcAcc, tgtAcc, customMapper) — plain Java mapper, type-safe |
condition = "..." predicate |
Annotation attribute | Edit.overIfPresent(...) for updates; Mapping.drop(...) for mappings |
The intent is identical; the calculus is different. MapStruct trades the typed-ref ergonomics for the ability to express
things like source = "user.address.street" as a single string. Telescope trades the string-path brevity for the
guarantee that everything you wrote against the source/target types compiles iff it still makes sense.
- You need embedded expression-language mapping bodies —
@Mapping(expression = "java(...)")or@Mapping(qualifiedByName = "...")qualifier dispatch — and want them inline in the annotation rather than as plain Java mappers passed toMapping.via(...) - You need MapStruct-specific declarative shapes telescope doesn't expose:
@InheritConfigurationrow-set reuse, full@SubclassMappingpolymorphic dispatch, or@MappingTargetupdate-in-place semantics (telescope'sMapper.patchcovers sparse overlay, not full update-into-existing) - The mappers are flat
Entity → Dtoonly — no bidirectional, deep navigation, sealed dispatch, multi-source merge, or effectful update needs — and you'd never reach for optics for anything else
- Your problem includes deep navigation alongside mapping —
Telescope.of(Company.class).each(Company::departments).field(Department::address).update(c, fn)— and you don't want a separate mapper for every level - You need bidirectional out of one definition —
Mapper.forward(...)and.backward(...)derive from the same row list, no inverse interface to write - You need to lift a mapping (or a field update) through an effect —
updateValidated,updateAsync,updateOptional,updateEither - You have multi-source mappers (
N → 1) —Telescope.merge(Target.class, from(A::id, T::id), …)returns aMapper<Sources, T>with a class-keyed bag; declared once, reusable - You have a sealed root to dispatch on —
Match.of(animal).when(Dog.class, …).when(Cat.class, …).exhaustive()gives compile-checked exhaustiveness over the permit list (and the@Bridgecodegen emits exactly this for sealed source types) - You're navigating a mix of records and POJOs at any depth and don't want to materialize intermediate DTOs to bridge between them
- You want the same
Telescope<S, A>type to do reading, updating, mapping, and conversion — one mental model instead of separate libraries
Published to Maven Central under io.github.eschizoid. The six artifacts in the family:
| Artifact | Role |
|---|---|
telescope-core |
The DSL — Telescope, Mapper, Mapping, Either / Validated, annotations. The one you add for the runtime path. |
telescope-internal |
Optic lattice + reflection helpers. Transitive only — pulled in automatically; users cannot reference it (JPMS qualified exports block visibility at compile time). |
telescope-codegen |
Optional @Focus / @BeanFocus / @Bridge annotation processor — see Compile-time field navigation. |
telescope-lombok |
Lombok-aware variant of the processor for @Data / @Value / @Builder POJOs. |
telescope-spring-boot-starter |
Spring Boot 4 autoconfig + Mapper<A, B> bean registry. |
telescope-quarkus |
Quarkus 3 CDI extension with the same registry shape. |
Add the processor only if you use the @Focus path. It's inert otherwise — the annotation is source-retention.
Gradle (Kotlin DSL):
dependencies {
implementation("io.github.eschizoid:telescope-core:1.1.1")
annotationProcessor("io.github.eschizoid:telescope-codegen:1.1.1")
}Maven:
<dependency>
<groupId>io.github.eschizoid</groupId>
<artifactId>telescope-core</artifactId>
<version>1.1.1</version>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.eschizoid</groupId>
<artifactId>telescope-codegen</artifactId>
<version>1.1.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>When both Lombok and telescope-lombok / telescope-codegen sit on the annotation processor path, list Lombok
first. Maven respects the declaration order of <annotationProcessorPaths>; Gradle respects the order of
annotationProcessor(...) calls:
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</path>
<path>
<groupId>io.github.eschizoid</groupId>
<artifactId>telescope-lombok</artifactId>
<version>1.1.1</version>
</path>
</annotationProcessorPaths>dependencies {
annotationProcessor("org.projectlombok:lombok:1.18.30")
annotationProcessor("io.github.eschizoid:telescope-lombok:1.1.1")
annotationProcessor("io.github.eschizoid:telescope-codegen:1.1.1")
}Both BridgeProcessor and LombokFocusProcessor round-defer emission to processingOver() when they detect that the
host class (or its @Bridge target) carries a Lombok-synthesizing annotation, so the build is order-tolerant — but
explicit ordering avoids relying on round-deferral and is the recommended posture. The Lombok-synthesizing trigger set
includes @Data, @Value, @Builder, @Getter, @Setter, the three *ArgsConstructor variants, @SuperBuilder,
and @experimental.Accessors.
Symptoms of mis-ordering without round-deferral (now harmless thanks to the deferral fix, but worth recognizing on older
versions): an emitted <X>Bridge whose forward/backward are no-ops, or a @Data class for which no <X>Telescope
lands. Both mean the telescope processor ran before Lombok patched the host class.
If your project has a module-info.java, add the requires and, for the runtime navigation path, an opens for the
package containing your records / beans / POJOs:
module com.acme.app {
requires io.github.eschizoid.telescope;
// Only needed if you use the RUNTIME path (Telescope.of, .ofBean, .map, .mapper).
// The codegen path (@Focus / @BeanFocus / @Bridge) needs no opens.
opens com.acme.model to io.github.eschizoid.telescope;
}The opens target is your package — the one telescope needs to reach into — not telescope's. Runtime navigation
binds accessors via MethodHandles.privateLookupIn(yourClass, MethodHandles.lookup()) and feeds the handles to
LambdaMetafactory for hot-path dispatch. Without an opens, the lookup fails with IllegalAccessException, surfaced
as:
Cannot access <YourClass> ... to build LambdaMetafactory <kind>. Add 'opens <pkg> to io.github.eschizoid.telescope;' to that module's module-info.java.
Copy the package from the error message into the opens directive.
telescope-internal comes in transitively via telescope-core's module declaration, but its packages are
qualified-exported to telescope-core only, so you cannot accidentally reference internal lattice types from your own
code. telescope-codegen is compile-time-only and isn't on the runtime module path.
Codegen escape hatch. The @Focus / @BeanFocus / @Bridge processors emit compile-time navigators that read
components and call constructors / builders / setters directly — no privateLookupIn, no LambdaMetafactory, no
opens requirement. If adding the opens is awkward (e.g., a downstream module you don't own), the codegen path
sidesteps the JPMS constraint entirely. See
Compile-time, reflection-free navigation.
Classpath users (no module-info.java). No opens needed — the JVM grants unnamed-module access automatically.
This section is JPMS-only.
A single class, Telescope<S, A>, where S is the root type and A is the leaf you focus on. The full method
inventory lives here as a reference; pick what you need by what you're trying to do, not by reading top-to-bottom.
| Method | What it does |
|---|---|
Telescope.of(Class<S>) |
Start at the root type. |
Telescope.lens(getter, setter) |
Build a single-focus telescope directly, no reflection. Used by @Focus codegen; handy for hot paths. |
Telescope.from(A).to(B).using(fwd, back) |
Build a Telescope<A, B> backed by an Iso — bidirectional type conversion that composes into longer paths. |
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<A, B>. |
Telescope.ofBean(Class<P>) |
Start a native POJO telescope — .field/.each navigate the bean directly, rebuilding via strategy (see Working with POJOs). |
.field(Class::accessor) |
Descend into a record field via method reference. Compile-checked. |
.fieldByName(String) |
Descend by field name — the runtime escape hatch for late-binding (config-driven paths). Runtime-checked: wrong name → runtime error. |
.fieldByName(String, Class<B>) |
Same as above with an inline type witness for cleaner var inference. The Class<B> is inference sugar, not validated against the actual field. |
.each(Class::collectionAccessor) |
Descend into a List/Set/Iterable field and broadcast over elements. Element type inferred from the method ref. Compile-checked. |
.list(Class::accessor) / .setField / .mapField / .optional |
Typed-container variants: keep the container type for later traversal. Return ListTelescope<S, X> / SetTelescope<S, X> / MapTelescope<S, K, V> / OptionalTelescope<S, X> — 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, ...). |
Telescope.asList(path) / asSet / asMap / asOptional |
Promote a pre-built Telescope<S, List<X>> (or Set/Map/Optional) into the typed subclass so the compile-checked terminal becomes available. Useful when composing path fragments. |
.eachValue(Class::mapAccessor) |
Like each, but for Map values (keys preserved). |
.whenPresent(Class::optionalAccessor) |
Like each, but for Optional — no-op if empty. |
.as(Class) |
Narrow to a sealed-type case. Non-matching values pass through. |
.filter(Predicate) |
Restrict to elements matching the predicate. |
.then(otherTelescope) |
Compose two telescopes. |
| Method | Returns |
|---|---|
.read(S) |
The first focused value. Throws if absent. |
.find(S) |
Optional<A> of the first focused value. |
.toList(S) |
List<A> of all focused values. |
.count(S) |
How many values are focused. |
.exists(S) |
true if there's at least one. |
.withIndex() |
Index-aware chainable view (Telescope.WithIndex<S, A>). Exposes .update(S, BiFunction<Integer, A, A>), .toList(S) → List<Indexed<A>>, .find(S), .count(S), .exists(S) — the same operations as the parent, with each focused value paired with its 0-based traversal position. |
| Method | Returns |
|---|---|
.set(S, A) |
New S with every focused value replaced by the given one. |
.update(S, Function<A, A>) |
New S with every focused value transformed. |
.updateAsync(S, fn, Executor) |
Bounded-concurrency async update; pass a fixed pool to cap concurrent invocations. |
.updateIndexed(S, BiFunction<Integer, A, A>) |
Transform every focused value with its 0-based position in traversal order. |
.toListIndexed(S) |
List<Indexed<A>> — every focused value paired with its position. |
.update(Telescope<S, X>, Function<X, X>) |
Accumulate an edit through a pre-built path; returns Telescope<S, S> carrying the running chain. See Multi-edit. Compile-checked. |
.with(Function<A, A>) |
Accumulate an edit at the current focus (inline-path equivalent of .update(path, fn)); returns Telescope<S, S>. Compile-checked. |
.apply(S) |
Run every accumulated .update(path, fn) / .with(fn) edit against the source, in insertion order. Returns a new S. |
Multi-edit packing (static factories — see Multi-edit):
| Method | Returns |
|---|---|
Telescope.all(Edit<S>...) |
Reusable Telescope<S, S> normalizer that runs every edit, in argument order, on apply(s). Compile-checked. |
Edit.over(Telescope<S, X>, Function<X, X>) |
Pair a pre-built path with its per-leaf transformation. Static-import-friendly: import static …Edit.over;. Compile-checked. |
final Telescope<User, String> name = Telescope.of(User.class).field(User::name);
name.read(alice); // "alice"
name.set(alice, "Bob"); // User with name="Bob"
name.update(alice, String::toUpperCase); // User with name="ALICE"final Telescope<User, String> city = Telescope.of(User.class)
.field(User::address)
.field(Address::city);
city.update(alice, String::toUpperCase);final Telescope<Team, String> userNames = Telescope.of(Team.class)
.each(Team::users)
.field(User::name);
userNames.update(team, String::toUpperCase);
userNames.toList(team); // List<String>sealed interface Event permits Created, Updated, Deleted {}
record Created(String id) implements Event {}
record Updated(String id, String diff, int revision) implements Event {}
record Deleted(String id) implements Event {}
final Telescope<Event, String> updatedDiff = Telescope.of(Event.class)
.as(Updated.class)
.field(Updated::diff);
updatedDiff.update(event, s -> s + "!"); // no-op if not Updated
updatedDiff.find(event); // Optional<String>record Profile(String id, Optional<String> nickname) {}
final Telescope<Profile, String> nick = Telescope.of(Profile.class).whenPresent(Profile::nickname);
nick.update(profile, String::toUpperCase); // no-op if nickname is emptyrecord Index(Map<String, Integer> byKey) {}
final Telescope<Index, Integer> values = Telescope.of(Index.class).eachValue(Index::byKey);
values.update(index, v -> v * 10);When you want a path that ends at the container (not at its elements), use the typed .list(Class::accessor) /
.setField(...) / .mapField(...) / .optional(...) instance methods. They return narrower subclasses
(ListTelescope, SetTelescope, MapTelescope, OptionalTelescope) whose typed terminal step (.each() /
.values() / .present()) descends into elements with zero runtime container dispatch — pure lattice composition,
fully compile-checked.
record Box(List<String> tags) {}
// Build the list-typed path once; descend on demand.
final ListTelescope<Box, String> tags = Telescope.of(Box.class).list(Box::tags);
final Telescope<Box, String> elements = tags.each(); // typed .each() — compile-checked
elements.update(box, String::toUpperCase);
// Set / Map / Optional follow the same shape.
record Cart(Set<Item> items) {}
final SetTelescope<Cart, Item> items = Telescope.of(Cart.class).setField(Cart::items);
items.each().field(Item::sku).update(cart, String::toUpperCase);For pre-built paths from elsewhere — composed Telescope.then(...) fragments, return types of helper methods, etc. —
promote them with Telescope.asList(...) / .asSet(...) / .asMap(...) / .asOptional(...) so the typed terminal
becomes available:
final Telescope<Company, List<Department>> raw = ...; // built somewhere else
Telescope.asList(raw).each().field(Department::name).update(co, String::toLowerCase);When a read or update depends on position, not just value, use the indexed forms. The index is the 0-based position in
traversal order (flat across nested each levels):
final Telescope<Team, String> members = Telescope.of(Team.class).each(Team::members);
members.toListIndexed(team); // [Indexed[0, "alice"], Indexed[1, "bob"], ...]
members.updateIndexed(team, (i, name) -> i + ": " + name); // "0: alice", "1: bob", ...final Telescope<Company, String> engineeringEmails = Telescope.of(Company.class)
.each(Company::departments)
.filter(d -> "Engineering".equals(d.name()))
.each(Department::teams)
.each(Team::users)
.field(User::email);
engineeringEmails.update(company, String::toLowerCase);
// Engineering emails lowercased; Sales untouched.record Stream(List<Event> events) {}
final Telescope<Stream, Integer> bumpRevisions = Telescope.of(Stream.class)
.each(Stream::events)
.as(Updated.class)
.field(Updated::revision);
bumpRevisions.update(stream, r -> r + 1);
// Created / Deleted events pass through unchanged.A plain update lambda only sees the focused value. When the transform needs sibling fields (the focused price needs
the SKU; the focused user needs the team name), close over the source — it's already in scope, since you pass it as the
first argument.
record Team(String name, List<User> users) {}
record User(String name, String bio) {}
static final Telescope<Team, User> USERS = Telescope.of(Team.class).each(Team::users);
// Set every user's bio to mention the team name. The lambda reads the sibling `team.name()`.
final Team stamped = USERS.update(team, (user) -> new User(user.name(), "Member of " + team.name()));This works for every variant — updateAsync, updateEither, updateValidated, updateOptional — because the root the
lambda needs is the same value you already hold. If the source is an expression rather than a variable, hoist it to a
local first (final var team = fetchTeam();) and close over that.
To apply several edits at different paths in one go, declare each path once as a static final, then pack the edits with
Telescope.all(over(...), over(...)). Every step is fully compile-checked.
Recommended form — Telescope.all(over(...), over(...)). Each over(PATH, fn) is one edit; Telescope.all(...)
folds them into a reusable Telescope<S, S> whose .apply(s) runs every edit in argument order.
import static io.github.eschizoid.telescope.Edit.over;
static final Telescope<Company, String> EMAILS = Telescope.of(Company.class)
.each(Company::departments)
.each(Department::teams)
.each(Team::users)
.field(User::email);
static final Telescope<Company, String> DEPT_NAMES = Telescope.of(Company.class)
.each(Company::departments)
.field(Department::name);
static final Telescope<Company, String> USER_NAMES = Telescope.of(Company.class)
.each(Company::departments)
.each(Department::teams)
.each(Team::users)
.field(User::name);
final Telescope<Company, Company> normalize = Telescope.all(
over(EMAILS, String::toLowerCase),
over(DEPT_NAMES, String::trim),
over(USER_NAMES, titleCase));
final Company done = normalize.apply(company);
normalize.apply(companyB); // reusable across sourcesover(path, fn) ties a Telescope<S, X> to a Function<X, X>; javac enforces the leaf type match. Each edit lives
on its own line, the count is visible at a glance, and there is no chain-blur between paths.
Single-edit shortcut. For one edit, just call update on the path:
EMAILS.update(company, String::toLowerCase);Chain accumulator (alternative). The same semantics as Telescope.all(...) are also available as a fluent chain via
.update(path, fn) and .with(fn) terminated by .apply(source) — useful when you want an inline path mid-chain
without naming it. The chain reads less clearly for multiple distinct paths (the navigation segments visually blur), so
prefer Telescope.all(over(...)) when packing two or more edits.
// Equivalent to the Telescope.all(...) form above:
Telescope.of(Company.class)
.update(EMAILS, String::toLowerCase)
.update(DEPT_NAMES, String::trim)
.update(USER_NAMES, titleCase)
.apply(company);
// Inline one-shot trailing edit on a pre-built chain:
Telescope.of(Company.class)
.update(EMAILS, String::toLowerCase)
.each(Company::departments).field(Department::name).with(String::trim)
.apply(company);Edits run sequentially in argument / insertion order; the second sees the first's result, not the original source. An
empty Telescope.all() (or an unedited chain) returns the source unchanged from .apply(...).
Two records that represent the same data (Entity ↔ Dto) convert through a bidirectional Iso that composes into
longer paths like any other telescope.
Write the two conversion functions yourself; telescope doesn't auto-map (that's MapStruct's territory). What's different is that the conversion becomes a value, so it threads into longer paths.
final Telescope<UserEntity, UserDto> userIso = Telescope.from(UserEntity.class)
.to(UserDto.class)
.using((e) -> new UserDto(e.id(), e.email(), e.name()), (d) -> new UserEntity(d.id(), d.email(), d.name()));
UserDto dto = userIso.read(entity); // forward
UserEntity updated = userIso.update(entity, (d) -> new UserDto(d.id(), d.email().toLowerCase(), d.name()));
// ↑ round-trips through DTO, returns EntityThe conversion is an Iso, which means it composes into longer paths:
record EntityPage(List<UserEntity> items, int total) {}
// Walk into the page, view each entity as a DTO, focus the email, lowercase it.
// Result is an EntityPage with UserEntity items — entities modified by round-tripping through DTO.
Telescope.of(EntityPage.class)
.each(EntityPage::items)
.then(userIso) // ← Iso participates in the lattice
.field(UserDto::email)
.update(page, String::toLowerCase);The recommended shape for record-to-record (and POJO↔POJO, and cross-paradigm) conversion: pass the source and target
classes up front, then varargs of MapStep rows. Recursion is the default. Same-named components identity-map,
nested records / POJOs recurse, List<X>↔List<Y> / Set<X>↔Set<Y> / Map<K, X>↔Map<K, Y> / Optional<X>↔Optional<Y>
lift the inner-element Iso through the container automatically (to any depth — List<Map<K, Set<X>>> works by
construction). You only spell the differences.
import static io.github.eschizoid.telescope.mapping.Mapping.to;
import static io.github.eschizoid.telescope.mapping.Mapping.via;
// All same-name, no overrides — the pure-copy 1-liner:
final Telescope<UserEntity, UserDto> userMapper = Telescope.map(UserEntity.class, UserDto.class);
// Tree-deep mapping with two renames — every other field figures itself out:
final Telescope<CompanyEntity, CompanyDto> companyMapper = Telescope.map(
CompanyEntity.class,
CompanyDto.class,
to(CompanyEntity::founded, CompanyDto::since), // top-level rename
to(UserEntity::name, UserDto::fullName)
); // applies wherever User↔UserDto recursesThe second example covers a 5-level structure — Company → Department → Team → User → Address — with List, Map, and
Optional containers at multiple depths. Both renames are declared once; the User::name → UserDto::fullName rule
fires every time recursion encounters the UserEntity ↔ UserDto type pair (in users[], in
department.head: Optional<User>, in company.ceo: Optional<User> — all three at once).
How to(...) overrides are keyed. Each to(srcAccessor, tgtAccessor) row carries its source and target record
classes implicitly via the method references. Telescope.map(...) reads them via SerializedLambda and uses
(sourceClass, targetClass) as the key. When the recursion lands on a matching pair, the row's correspondence is
applied; otherwise the recursion auto-resolves that component.
Cycle handling. Self-referencing structures (a User that contains Optional<User>) terminate naturally — the
recursion caches each type pair as it descends, and re-entry returns the in-progress entry instead of recursing forever.
Override forms. Static-import-friendly factories on Mapping:
| Factory | Purpose | MapStruct equivalent |
|---|---|---|
to(src, tgt) |
Rename, same leaf type | @Mapping(source, target) |
to(src, tgt, fwd, bwd) |
Bidirectional typed transform | @Mapping(source, target, qualifiedBy) |
forward(src, tgt, fn) |
Forward-only typed transform | (separate @Mapper interface) |
toOrElse(src, tgt, default) |
Null-coalesce to a default value | @Mapping(defaultValue = "...") |
toOrElseGet(src, tgt, sup) |
Null-coalesce via a Supplier |
@Mapping(defaultExpression = "java(…)") |
enumTo(src, tgt, SE, TE) |
By-name enum mapping with build-time exhaustiveness | @ValueMapping(source = "X", target = "Y") |
via(src, tgt, mapper) |
Drop in a pre-built nested mapper | (composition by hand) |
constant(tgt, value) |
Forward-only literal at the target slot | @Mapping(constant = "...") |
compute(tgt, supplier) |
Forward-only supplier-computed value | @Mapping(expression = "java(...)") |
drop(src) |
Skip the source field; backward zero-fills it | @Mapping(ignore = true) |
Example — three of those rows together:
import static io.github.eschizoid.telescope.mapping.Mapping.*;
Telescope.mapper(
UserEntity.class,
UserDto.class,
to(UserEntity::name, UserDto::fullName),
toOrElse(UserEntity::region, UserDto::region, "EMEA"),
enumTo(UserEntity::status, UserDto::status, EntityStatus.class, DtoStatus.class)
);The via(...) row works in two flavors: pass an accessor-typed mapper (e.g.,
Mapper<List<UserEntity>, List<UserDto>>) and telescope uses it as-is, or pass an element-typed mapper
(Mapper<UserEntity, UserDto>) and telescope detects the accessor's container shape (List, Set, Optional, Map
values) and auto-lifts the mapper through it via Iso.liftList / liftSet / liftOptional / liftMapValues. One row
either way — no separate viaList / viaSet factories.
Recursion is auto by default — there's no auto() row to declare.
Result threads through longer paths like any other telescope:
Telescope.of(EntityPage.class)
.each(EntityPage::items)
.then(companyMapper)
.field(CompanyDto::name)
.update(page, String::toUpperCase); // entities modified by round-tripping through the DTOTelescope.mapper(A.class, B.class, ...) — Mapper sibling. Same factory, returns Mapper<A, B> instead of
Telescope<A, B>. Same row syntax; same recursion. Useful for nested-mapper composition via via(src, tgt, mapper).
For lossy or one-way conversions (dropping fields, non-invertible transforms), use from/to/using with hand-written
functions. Telescope still won't auto-discover anything fuzzy — recursion only follows exact name matches plus the
same-shape container rule.
Telescope's deep-mapping factory handles any combination of records and POJOs through one entry point. The same
Telescope.map(A.class, B.class, ...) call covers record↔record, POJO↔POJO, and the cross-paradigm record↔POJO mix at
any depth — the engine picks per side whether to drive the canonical constructor (records) or Beans.autoWriter (POJOs)
at every type pair the recursion encounters. The alternative is to navigate the POJO directly with
Telescope.ofBean(...). Either way updates are immutable.
The same factory described under Type conversion handles POJO↔POJO and cross-paradigm record↔POJO
pairs without ceremony — components match by name on either side (Pojo::getX / RecordOrPojo::x normalized to x),
nested POJOs recurse, and container hops auto-lift. The POJO mechanics this section covers are the bean-construction
lever (writeBean / writeBeans) for when the auto-detect ladder can't pick a strategy.
import static io.github.eschizoid.telescope.mapping.Mapping.to;
import static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.SETTERS;
import static io.github.eschizoid.telescope.mapping.WriteHint.writeBeans;
class LegacyUser {
/* getId(), getEmail(), getName() + no-arg ctor + setters */
}
record UserRecord(String id, String email, String name) {}
// Same-name 1-liner — every getter/component lines up by normalized name.
final Telescope<LegacyUser, UserRecord> bridge = Telescope.map(LegacyUser.class, UserRecord.class);Renames (Mapping.to(srcAcc, tgtAcc)), typed transforms (Mapping.to(srcAcc, tgtAcc, fwd, bwd)), null-coalescing
defaults (Mapping.toOrElse / toOrElseGet), by-name enum mapping (Mapping.enumTo), and pre-built nested mappers
(Mapping.via(srcAcc, tgtAcc, mapper)) work the same way they do for records — see the rows under
Type conversion.
writeBean — pin a POJO write strategy. Beans.autoWriter picks a ladder: builder() → no-arg ctor + setters →
no-arg ctor + reflective field injection → single public all-args ctor (when compiled with -parameters and ctor
parameter names match the property names). For classes the auto path refuses (immutable all-args-only POJOs without
-parameters, ambiguous multi-ctor classes), pass an explicit WriteHint.writeBean(target, strategy) row to force one
of BUILDER / SETTERS / FIELDS / CONSTRUCTOR:
import static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.CONSTRUCTOR;
import static io.github.eschizoid.telescope.mapping.WriteHint.writeBean;
// OrderPojo has a public (String sku, int qty) ctor, no builder, no setters — autoWriter would
// refuse without -parameters. The hint forces the CONSTRUCTOR strategy explicitly.
final Telescope<OrderRecord, OrderPojo> conv = Telescope.map(
OrderRecord.class,
OrderPojo.class,
writeBean(OrderPojo.class, CONSTRUCTOR),
to(OrderRecord::sku, OrderPojo::getSku)
);Validation is eager: a misconfigured hint (BUILDER on a no-builder class, hint targeting a record, duplicate hint,
unused hint) throws at Telescope.map(...) time — not on first iso.to() deep in production. And with
telescope-codegen on the annotation-processor path, the structural rejections that don't need the live classpath — a
hint targeting a record, a duplicate hint — move up to compile time: statically-visible map(...) / mapper(...)
call sites are replayed by the verifier and those violations surface as compile errors with the identical diagnostic
text (details in telescope-codegen). The builder-feasibility and
unused-hint checks stay at construction time (both still eager and loud).
writeBeans(STRATEGY) — one default for every bean target. When every entity in the recursion shares the same
construction shape (the common JPA case: every @Entity needs SETTERS so Hibernate's identity assignment fires), one
writeBeans(SETTERS) row replaces N per-class enumerations. Per-class writeBean(X.class, ...) still wins for class
X. At most one writeBeans(...) default per call.
import static io.github.eschizoid.telescope.mapping.WriteHint.WriteStrategy.SETTERS;
import static io.github.eschizoid.telescope.mapping.WriteHint.writeBean;
import static io.github.eschizoid.telescope.mapping.WriteHint.writeBeans;
final Mapper<Order, OrderEntity> orderMapper = Telescope.mapper(
Order.class,
OrderEntity.class,
writeBeans(SETTERS), // default for OrderEntity, CustomerEntity, LineItemEntity, AddressEmbeddable, …
writeBean(CashRegisterEntity.class, FIELDS) // override on one specific target
);Composing through a bridge. The mapping result is a Telescope<A, B>, so it threads through a longer path the same
way any other telescope does:
Telescope.of(Page.class) // Page is a record holding List<LegacyUser>
.each(Page::items)
.then(bridge) // each POJO ↔ record at this hop
.field(UserRecord::email)
.update(page, String::toLowerCase);Telescope.mapper(...) — the Mapper<A, B> sibling. Same deep recursion, but the return is a Mapper<A, B>
exposing forward / backward / read / patch / asTelescope / liftList / liftSet / liftOptional /
liftMapValues. patch(base, partial) overlays non-null fields of partial onto base — useful for sparse JSON /
form updates. asTelescope() returns the mapper as a Telescope<A, B> for .then(...) composition into a longer typed
path (bridging record-side navigation into entity-side leaves, or vice versa). The lift* methods promote an
element-level mapper to a container-level mapper without going through a via(...) row — useful when the lifted mapper
is the call-site root (e.g., a bulk handler that converts a List<Order> payload to List<OrderEntity>).
final Mapper<UserBean, UserView> mapper = Telescope.mapper(UserBean.class, UserView.class);
final UserView withFresh = mapper.patch(view, new UserView(null, "new@x", null));
// Container promotion for a bulk endpoint:
final Mapper<List<UserBean>, List<UserView>> bulk = mapper.liftList();
final List<UserView> view = bulk.forward(beans);
// Thread the conversion into a longer Telescope chain via .then():
Telescope.of(Page.class)
.each(Page::items)
.then(mapper.asTelescope())
.field(UserView::email)
.update(page, String::toLowerCase);For a worked end-to-end demo using every public Mapping / Mapper / Telescope row through a Spring Boot 4, Hibernate, and
Jackson REST pipeline, see examples/springboot/.
@Bridge — reflection-free, compile-checked (any pair). The codegen counterpart to Telescope.map(...). Annotate
the source you own with the target type; the processor generates <Source>Bridge.BRIDGE, a Telescope<Source, Target>
built from direct component/getter reads and constructor / builder / setter calls. Both sides may be records or POJOs —
record⇄record, record⇄POJO, POJO⇄POJO. Fields match by name (a bijection); a name mismatch or a missing construction
strategy is a compile error, not a runtime one:
import io.github.eschizoid.telescope.annotations.Bridge;
@Bridge(UserDto.class)
record UserEntity(String id, String email) {}
// Generated alongside: UserEntityBridge.BRIDGE (a Telescope<UserEntity, UserDto>)
UserDto dto = UserEntityBridge.BRIDGE.read(entity);
// BRIDGE is a Telescope value, so it threads through a longer path:
final Page lowered = Telescope.of(Page.class)
.each(Page::entities) // each UserEntity on the page
.then(UserEntityBridge.BRIDGE) // view it as a UserDto
.field(UserDto::email)
.update(page, String::toLowerCase);It auto-detects each side's strategy at compile time (record canonical constructor; POJO name-matched constructor →
builder → no-arg + setters). Renames and per-field transforms can't be expressed in an annotation — use the runtime
map / from/to/using for those. Wire up telescope-codegen as shown under Installation.
from/to/using — hand-written. When the mapping is lossy, one-directional, or just custom, write both functions
yourself:
public static final Telescope<LegacyUser, UserRecord> USER_BRIDGE = Telescope.from(LegacyUser.class)
.to(UserRecord.class)
.using(
(l) -> new UserRecord(l.getName(), l.getEmail(), l.getAddress()),
(r) -> {
final var u = new LegacyUser();
u.setName(r.name());
u.setEmail(r.email());
u.setAddress(r.address());
return u;
}
);When you'd rather not define a mirror record, navigate the POJO directly. .field(Pojo::getX) reads via the getter;
set/update rebuild the POJO immutably with that one property changed (write strategy auto-detected per type: builder
→ setters → field injection). Deep paths and .each(...) compose like records:
Telescope.ofBean(LegacyUser.class)
.field(LegacyUser::getAddress)
.field(Address::getCity)
.update(user, String::toUpperCase); // new LegacyUser; the original is untouchedCost — measured. ofBean rebuilds the whole POJO and re-reads every getter at each level of the path: a 3-level
update benchmarks at ~442 ns/op (~18x a hand-written copy, ~1.8x record reflection — see
benchmarks/). Fine for ordinary use (sub-microsecond); for a hot loop over many objects,
convert to a record once with Telescope.map(Pojo.class, Record.class) and navigate the record (or use @BeanFocus
codegen) instead. The runtime deep-mapping bridges are cheaper — ~114 ns (POJO→record) and ~142 ns (POJO↔POJO), in line
with the record→record mapper (~112 ns).
Aliasing — beans aren't records. An update rebuilds the spine (the path to the changed field) with fresh objects and shares references to untouched subtrees. With records that's always safe; with mutable POJOs the new and old object share the same off-path sub-POJO instances, so mutating a shared sub-object afterward shows through both. Treat the shared parts as effectively immutable.
Telescope.map(...) / @Bridge match by exact name and need a same-named field on each side (with optional rename rows
via Mapping.to(srcAcc, tgtAcc)); nested collections recurse automatically. The FIELDS write strategy (and ofBean's
field-injection fallback) uses setAccessible, so under JPMS the POJO's package must be opens'd to
io.github.eschizoid.telescope — CONSTRUCTOR / BUILDER / SETTERS (and all of @Bridge) use public members only.
MapStruct's mapping is a black box — the generated code is the only record of what got mapped, dropped, or converted.
telescope makes it a first-class value. Every Mapper / ForwardMapper, and every Telescope navigator, answers two
questions:
explain()— the static structure: which fields correspond, which were skipped (and why), which change type. No input needed.trace(input)— the same rows with the values for one conversion filled in.
final Mapper<UserDto, User> mapper = Telescope.mapper(UserDto.class, User.class,
Mapping.to(UserDto::firstName, User::givenName),
Mapping.to(UserDto::birthDate, User::birthDate, LocalDate::parse, LocalDate::toString),
Mapping.drop(UserDto::id));
System.out.println(mapper.explain());
// Mapped:
// ✓ firstName → givenName
//
// Skipped:
// • id (ignored)
//
// Transformations:
// • birthDate(String) → LocalDate
System.out.println(mapper.trace(new UserDto("Ada", "2020-01-02", 7L)));
// ✓ firstName "Ada" → givenName "Ada"
// • birthDate "2020-01-02" → birthDate LocalDate[2020-01-02]
// • id → (ignored)The left column is aligned across every section (the widest cell sets the width), so markers, fields, and each → line
up as one table.
The render is a view; the data is the API. explain() returns an OpticReport you assert on directly — pull a
typed slice instead of scraping text:
// completeness test — a strict mapper skips nothing by construction
assertThat(mapper.explain().skipped()).isEmpty();
assertThat(mapper.explain().mapped()).contains(new Mapped("firstName", "givenName"));Slices: mapped(), transformations(), skipped(), unusedSources(), and hops() (for a navigator's path).
You don't have to call explain() / trace() by hand. Each mapper logs its own introspection through
java.lang.System.Logger (java.base — zero dependency, routes to whatever backend your app already runs):
DEBUG—explain()once, when the mapper is built.TRACE—trace(input)on everyforward().
Loggers are named by type pair, so you enable one mapper or the whole library from your existing config — no code
change. In Spring Boot application.properties:
logging.level.io.github.eschizoid.telescope.mapper.UserDto.User=TRACE # one mapper, values per conversion
logging.level.io.github.eschizoid.telescope.mapper=DEBUG # every mapper's structure at buildor directly in logback.xml:
<logger name="io.github.eschizoid.telescope.mapper.UserDto.User" level="TRACE"/>
<logger name="io.github.eschizoid.telescope.mapper" level="DEBUG"/>The log calls are always present and gated purely by level, so they cost nothing when off (guarded before the message is
ever built). <Source> / <Target> are simple class names. One backend nuance: through Spring Boot's default
jul-to-slf4j bridge both lines render at DEBUG (the bridge maps System.Logger.TRACE onto SLF4J DEBUG); the level
threshold still separates them — DEBUG shows structure, TRACE adds the per-conversion values.
The reflection-based Telescope.of(User.class).field(User::name) path resolves the field name at runtime — fast enough
for ordinary use (~100 ns), but a typo or a rename surfaces as a runtime error, not a compile error. Annotate the types
you navigate with @Focus (records) or @BeanFocus (POJOs) and add the processor to your build; for each annotated
type the processor emits a sibling fluent typed path navigator that reads like the runtime DSL but is fully
compile-checked and reflection-free.
Same path, two ways. The two surfaces produce the same terminal Telescope<Company, String> and the same update
result — they only differ in when the path is resolved (runtime vs javac) and how it's dispatched (reflection vs
direct method-ref + constructor calls). On the benchmarks, the reflective deep-field path
measures ~262 ns/op; the codegen lens path it desugars to measures ~45 ns/op (~5.8x).
// Reflective — runtime resolution, ~100 ns per field hop
Telescope.of(Company.class)
.each(Company::departments).each(Department::teams)
.each(Team::users).field(User::email)
.update(company, String::toLowerCase);
// Compile-time, reflection-free — same Telescope, generator-built
CompanyTelescope.of()
.departments().each().teams().each()
.users().each().email()
.update(company, String::toLowerCase);import io.github.eschizoid.telescope.annotations.Focus;
@Focus record Address(String city, String zip) {}
@Focus record User(String name, int age, Address address) {}
@Focus record Team(String name, List<User> users) {}
@Focus record Company(String name, List<Team> teams) {}
// Generated: <X>Telescope<R> per annotated type plus a step class per collection-shaped component.
// Usage reads like the reflective DSL — but every hop is type-checked by javac and every read /
// rebuild is a direct method-ref + constructor call (no reflection):
final Telescope<Company, String> userNames = CompanyTelescope.of()
.teams().each() // step over List<Team> → TeamTelescope<Company>
.users().each() // step over List<User> → UserTelescope<Company>
.name(); // terminal Telescope<Company, String>
final Company shouted = userNames.update(company, String::toUpperCase);
// Single fields are just as direct:
UserTelescope.of().address().city().update(alice, String::toUpperCase);Each scalar component yields a terminal Telescope<R, T>; each sub-record component (also @Focus-annotated) yields a
<Sub>Telescope<R> navigator to keep navigating; each container component yields a small step class whose .each()
(List/Set/Iterable), .eachValue() (Map values, keys preserved), or .whenPresent() (Optional) returns the element's
navigator when the element is itself annotated, or a terminal Telescope otherwise. At any hop, .get() returns the
current Telescope — so a step or navigator is a navigator, but every leaf is the same Telescope<R, X> value the
reflective DSL gives you.
Ops at every hop, effects included. Every generated navigator and Step also forwards the full Telescope
operation surface — read / find / toList / count / exists / set / update / updateIndexed /
toListIndexed / then plus the four effect methods updateAsync (with or without Executor) / updateOptional /
updateEither / updateValidated. You don't need to terminate with .get() first; the navigator stands in for the
wrapped Telescope at any intermediate hop. So
CompanyTelescope.of().teams().each().users().each().updateAsync(company, svc::lookup, pool) returns a
CompletableFuture<Company> directly, with the effect threaded through the generated chain.
Bridge hops — conversion as a navigator step. If a type carries both @Focus/@BeanFocus (so it has a *Telescope
navigator) and @Bridge(Target.class) (so it has a *Bridge.BRIDGE), the navigator gains a fluent as<Target>()
method that chains the bridge in. The navigator becomes a single compile-checked surface for both navigation and
conversion, crossing paradigms naturally (record↔record, record↔POJO, POJO↔POJO):
@Focus
@Bridge(UserDto.class)
record UserEntity(String id, String email) {}
@Focus
record UserDto(String id, String email) {}
// Navigate through the bridge into a target field, then update. The Iso round-trips, so the
// result is a new UserEntity:
final UserEntity lowered = UserEntityTelescope.of()
.asUserDto() // → UserDtoTelescope<UserEntity>
.email() // → Telescope<UserEntity, String>
.update(entity, String::toLowerCase);The return type degrades to a terminal Telescope<R, Target> when the target isn't itself annotated (so there's no
<Target>Telescope navigator to chain into). The reverse direction (target's navigator getting .asSource()) still
goes through .then(SourceBridge.BRIDGE.reverse()) for now — forward only at the navigator level.
Gradle wiring:
implementation("io.github.eschizoid:telescope-core:1.1.1")
annotationProcessor("io.github.eschizoid:telescope-codegen:1.1.1")@Focus and @BeanFocus are source-retention and inert without the processor, so annotating costs nothing if you don't
wire up codegen. Only top-level records / classes are supported (the generated top-level navigator can't reference a
nested type's constructor).
@BeanFocus — the POJO analog. Same surface as @Focus, applied to a POJO with either a static builder() or a
no-arg constructor + setX setters. Field injection isn't available to generated code, so a POJO that exposes neither
is a compile error; reach for runtime Telescope.ofBean in that case. Compare ~488 ns for the runtime ofBean 3-level
path vs ~15 ns for a generated @Bridge conversion in the benchmark — the navigator gets you the same reflection-free
win for navigation.
import io.github.eschizoid.telescope.annotations.BeanFocus;
@BeanFocus public class UserBean { /* getId/getEmail + setters, or a static builder() */ }
// Generated alongside: UserBeanTelescope<R> with the same fluent surface as a record navigator.
UserBeanTelescope.of().email().update(user, String::toLowerCase); // no reflectionThe same path that powers .update(...) lifts through four effects with one method change: async,
all-or-nothing, short-circuit, and error-accumulating. Validate every email in a Batch and report all the
bad ones in one call? Two lines. Run an HTTP normalization call for every focused element with bounded concurrency? Pass
an Executor. The DSL writes the structural plumbing; you supply the per-element function.
Pick the method by the function you have — the type system picks the applicative. Chaining stages of different effects
is handled by the bridge methods on Either / Validated; see Chaining stages.
| Your function returns | Call this | You get back | Semantics |
|---|---|---|---|
A → A (pure) |
update(...) |
S |
total, synchronous |
CompletableFuture<A> |
updateAsync(...) |
CompletableFuture<S> |
sequence; any failure propagates |
Optional<A> |
updateOptional(...) |
Optional<S> |
any empty propagates |
Either<E, A> |
updateEither(...) |
Either<E, S> |
short-circuit on first Left |
Validated<E, A> |
updateValidated(...) |
Validated<E, S> |
accumulate every error |
Picking between updateEither and updateValidated:
- Use
updateEitherwhen failures should halt work: parsers where a malformed root makes children meaningless, dependent stages, expensive per-element calls. Subsequent elements are never even called. - Use
updateValidatedwhen you want every problem reported: form validation (show the user every wrong field at once), batch quality reports, lightweight predicates over many elements. Every element is processed; failures are collected.
The difference is control flow, not just result shape. You can't recover short-circuit behavior by post-converting a Validated result, and you can't recover all-errors reporting from an Either that stopped after the first failure.
Each effectful method works on its own. Pick the one that matches the function you have. The examples below share this tiny domain:
record Order(String id, String email) {}
record Batch(List<Order> orders) {}
// Reusable path declared once, used by every example below.
static final Telescope<Batch, String> ALL_EMAILS = Telescope.of(Batch.class).each(Batch::orders).field(Order::email);updateAsync — fan out, gather back.
// Hit an HTTP service to normalize every email in parallel. The future completes
// when every per-element future has completed; failures propagate.
final CompletableFuture<Batch> done = ALL_EMAILS.updateAsync(batch, normalizer::normalizeAsync);The path navigation, the per-element future creation, and the structural rebuild collapse into one method call. The
naive alternative — stream().map(CompletableFuture::supplyAsync).collect(toList()) followed by
CompletableFuture.allOf(...) followed by manual reconstruction of the Batch — is the boilerplate this replaces.
updateValidated — collect every error.
record EmailError(String email, String reason) {}
final Validated<EmailError, Batch> result = ALL_EMAILS.updateValidated(batch, this::checkEmail);
return result.fold(this::respondBadRequest, this::save);
// The per-element predicate lives in a named method — easier to read, easier to test:
private Validated<EmailError, String> checkEmail(final String email) {
if (!email.contains("@")) return Validated.invalid(new EmailError(email, "missing @"));
return Validated.valid(email.toLowerCase());
}Every bad email across the entire batch is reported, not just the first one. The applicative does the accumulation. The user code never touches an error list directly.
updateEither — short-circuit on the first failure.
record ParseError(String input, String message) {}
final Either<ParseError, Batch> result = ALL_EMAILS.updateEither(batch, EmailParser::tryParse);
return result.fold(this::respondError, this::save);The first email that fails to parse wins; later emails aren't even called. Use this when the first failure is enough —
it's strictly cheaper than updateValidated because there's no accumulation.
updateOptional — all-or-nothing.
// If any single email fails to mask (returns Optional.empty), the whole batch becomes empty —
// partial state is impossible.
final Optional<Batch> masked = ALL_EMAILS.updateOptional(batch, this::tryMask);This is the right tool when a partially-updated structure would be a bug, not a feature.
By default updateAsync invokes fn synchronously per focused element; concurrency is whatever the futures returned by
fn already had. To cap concurrent invocations, pass an Executor:
try (final var pool = Executors.newFixedThreadPool(10)) { // ≤10 in-flight HTTP calls
final CompletableFuture<Batch> done = path.updateAsync(batch, this::fetchAsync, pool);
done.join();
}fn is wrapped in CompletableFuture.supplyAsync(..., pool), so the executor bounds when fn is called. For fully
non-blocking fn (e.g. HttpClient.sendAsync) that's the right bound; for blocking work inside fn, the pool size is
the literal upper bound on in-flight operations.
Either<L, R> and Validated<E, A> are sealed records shipped with the library, no Vavr/Arrow dependency. The typical
handler is .fold(...):
return parsed.fold(this::respondError, this::save);Pattern matching also works when you need to destructure the value, but Java's inference can't elide the type parameters
in switch arms, so .fold(...) is usually less noisy:
return switch (parsed) {
case Either.Right<ParseError, Company>(var c) -> save(c);
case Either.Left<ParseError, Company>(var err) -> respondError(err);
};Both Either and Validated expose the same compact handler API:
| Method | Notes |
|---|---|
fold(onLeft, onRight) |
Collapse both sides into a single value. Usually what you want. |
map(f) |
Transform the success side; failure passes through. |
isLeft() / isRight() |
Boolean tests, when a switch would be overkill. |
mapLeft(f) (Either) |
Transform the failure side; useful for normalizing error types at a boundary. |
mapErrors(f) (Validated) |
Same idea as mapLeft, applied to every accumulated error. |
swap() (Either) |
Flip left and right. |
flatMap(f) (Either) |
Sequence two Eithers; short-circuits on the first Left. |
andThen(f) (Validated) |
Sequence two Validateds; short-circuits on Invalid (use combine to accumulate). |
Validated.combine(a, b, f) (Validated, static) |
Combine two Validateds; accumulates errors across both branches. |
toValidated() (Either) |
Bridge to Validated: Left(e) becomes a single-element Invalid([e]). |
toEither() (Validated) |
Bridge to Either: Invalid(errs) becomes Left(errs). |
flatMapAsync(f) (both) |
Sequence an async stage; failures stay in the result, only success runs. |
toOptional() (both) |
Drop the error and bridge to JDK Optional. Use when downstream only cares about the success path. |
getOrElse(default) (both) |
Return the success value, or default on failure. |
getOrElseGet(supplier) (both) |
Same, with a lazy default for expensive cases. |
combineAll(List<…>) (Validated, static) |
Combine a list of validations into a Validated<E, List<A>>; accumulates every error. |
Multi-stage flows use the bridge methods on Either / Validated to keep the error channel consistent across different
effects. The pattern is: normalize each stage's error type with mapErrors / mapLeft, bridge between accumulating and
short-circuiting with toEither / toValidated, then flatMap / andThen for sync stages or flatMapAsync when the
next stage returns a CompletableFuture.
Sync-only example — validate emails, then look up users, with one unified List<String> error channel:
// Stage 1: collect every bad email, then hand off to short-circuit code
// → Either<List<String>, Batch>
final Either<List<String>, Batch> afterEmails = emailPath
.updateValidated(batch, this::checkEmail)
.mapErrors(EmailError::reason) // EmailError -> String
.toEither(); // accumulating -> short-circuit
// Stage 2: short-circuit on the first user lookup failure, normalize its error too
// → Either<List<String>, Batch>
final Either<List<String>, Batch> afterUsers = afterEmails.flatMap((b) ->
userPath.updateEither(b, this::lookupUser).mapLeft((err) -> List.of(err.id() + " not found"))
);Crossing into an async stage uses flatMapAsync, which mirrors flatMap but accepts a function returning a
CompletableFuture. Errors remain in the Either (or Validated) result; only the success side runs asynchronously:
return afterUsers.flatMapAsync(ok -> enrichPath.updateAsync(ok, this::enrich));
// → CompletableFuture<Either<List<String>, Batch>>-
Records only. Field navigation rebuilds via the record's canonical constructor. Non-record types throw at runtime with a clear message. To work with POJOs, bridge them to a record — see Working with POJOs.
-
Method references, not lambdas.
User::nameworks;u -> u.name()doesn't. The compiler synthesizes a name likelambda$xx$0and we can't recover the field name from it. The library throws a clear error. -
List<T>element types are inferred from the method-ref signature, not from runtime generics. That's whyeach(Team::users)works without a type witness —Team::usershas compile-time typeFunction<Team, List<User>>and Java unifiesE = User. -
Reflection cost. Field access uses
RecordComponent.getAccessor().invoke(...)and the canonical constructor — roughly ~100 ns per reflective field access, vs ~10 ns for a hand-written record copy; the reflection-freelenspath (@Focuscodegen) sits in between. Fine for almost everything; matters for tight loops. Seebenchmarks/for measured numbers. -
Sibling-context updates close over the source. A plain
updatelambda only sees the focused value. If you need to read sibling fields (e.g., focusLineItem::unitPricebut want the siblingskuto call a price service), the source is already in scope as the first argument — reference it inside the lambda (update(order, item -> … order.sku() …)). Hoist the source to a local first if it's an expression. -
One documented runtime-check point on the runtime DSL. Every typed entry point (
.field(Accessor),.each(Accessor),.list(Accessor)/.set/.map/.optionaland their typed terminals,.eachValue(Accessor),.whenPresent(Accessor), the staticTelescope.asList/asSet/asMap/asOptionalpromotions, the bridges,.with(fn),.apply(S), everyupdate*variant) is fully compile-checked. One escape hatch is not compile-checked, by design, and it's named so the call site says so:.fieldByName(String)/.fieldByName(String, Class<B>)— late-bound field name (config-driven paths).javaccan't verify the name exists or that the inferred type matches the actual field. Wrong name → runtime error.
For zero runtime-check points, use the
@Focus/@BeanFocus/@Bridgeannotation processors — they generate a typed<X>Telescope<R>navigator at compile time, with every step a typed method call. -
Versioning policy — semver. Source and binary compatibility across minor versions; breaks only on majors.
Three modules with a hard public/internal boundary:
telescope-core— the public DSL.Telescope<S, A>plus theMapping/Mapper/Edit/ effects vocabulary and the@Focus/@BeanFocus/@Bridgeannotations.telescope-internal— the optic lattice (Iso,Lens,Prism,Affine,Traversal,Getter,Setter,Fold),Kind/ApplicativeHKT-emulation, and reflection helpers. Packages are qualified-exportedto io.github.eschizoid.telescopeonly via JPMS, so the lattice types never appear on your classpath at compile time. The lattice is the substrate, not the API.telescope-codegen— compile-time-only annotation processor. Not required on the runtime module path.
Each DSL method builds the appropriate optic and composes it via the lattice — Telescope.of(C.class) is
Iso.identity(), .field(C::name) is a Records.fieldLens(name) wrapped as Lens<C, X> and composed via
Traversal.then(Lens), .each(C::items) is two .then calls (one for the container Lens, one for the element
Traversal), .as(Updated.class) is Prism.downcast(Updated.class) via Traversal.then(Prism), and so on. Operations
(read, set, update, toList, count, exists) delegate to the underlying optic's methods. Composition rules
(Lens.then(Prism) = Affine, Iso.then(Iso) = Iso, etc.) and laws (get-set, set-get, set-set, iso round-trip, prism
partial round-trip) live in the lattice and are pinned by OpticLawsTest.
If you ever want the optic types as public API (Monocle interop, or extending the library), flip the
exports … to io.github.eschizoid.telescope lines in telescope-internal's module-info.java to unqualified exports.
The types are already there; the JPMS export list is the gate.
./gradlew spotlessApply # format code
./gradlew build # compile, run testsThe integration tests use Testcontainers and require a reachable Docker daemon. Linux and macOS Docker Desktop both work out of the box (Testcontainers 2.x autodetects the socket). Without a reachable daemon the integration tests are silently skipped, not failed.
Apache 2.0 — see LICENSE.
