DynamicOptic
DynamicOptic is a runtime path through nested data structures in ZIO Blocks. It is the untyped,
dynamically-constructed counterpart to the typed Optic[S, A]. Where typed optics are bound to specific
Scala types at compile time, a DynamicOptic is a sequence of navigation steps that can be built, composed, and
applied at runtime:
import zio.blocks.schema._
// Build a path: .users[0].name
val path = DynamicOptic.root.field("users").at(0).field("name")
// Navigate a DynamicValue
val data = DynamicValue.Record(
"users" -> DynamicValue.Sequence(
DynamicValue.Record("name" -> DynamicValue.string("Alice"))
)
)
val result = data.get(path).one
// Right(DynamicValue.Primitive(PrimitiveValue.String("Alice")))
Motivation
Most of the time you work with typed Optic[S, A] values — they are statically verified and provide type-safe
get/set/modify operations. DynamicOptic is useful when:
- The path is determined at runtime — user input, configuration, query parameters
- You are working with
DynamicValue— navigating, modifying, or querying schema-less data - You need schema introspection — walking a
SchemaorReflectstructure programmatically - Serializing optic paths —
DynamicOptichas its ownSchema[DynamicOptic]and can be persisted
The relationship between typed and dynamic optics:
Typed World Dynamic World
┌──────────────────┐ ┌──────────────────┐
│ Optic[S, A] │─────────────│ DynamicOptic │
│ │ toDynamic │ │
│ Lens, Prism, │ │ Sequence of │
│ Optional, │ │ Node steps │
│ Traversal │ │ │
├──────────────────┤ ├──────────────────┤
│ Operates on │ │ Operates on │
│ typed values │ │ DynamicValue, │
│ (case classes, │ │ Schema, │
│ sealed traits) │ │ Reflect │
└──────────────────┘ └──────────────────┘
The Optic[S, A] and DynamicOptic types serve complementary roles in ZIO Blocks' optics system. Optic[S, A] provides compile-time type safety through macros or manual construction, operates on typed Scala values, and can be converted to a DynamicOptic via the optic.toDynamic method. In contrast, DynamicOptic performs runtime type checking and is constructed through a builder API or path interpolator, operating directly on DynamicValue, Schema, and Reflect representations.
Design & Structure
DynamicOptic is modeled as a case class wrapping an IndexedSeq[Node], where each Node represents one step in a navigation path:
DynamicOptic(nodes: IndexedSeq[Node])
sealed trait DynamicOptic.Node
├── Field(name: String) — named field in a record
├── Case(name: String) — specific case in a variant
├── AtIndex(index: Int) — element at index in a sequence
├── AtIndices(index: Seq[Int]) — elements at multiple indices
├── AtMapKey(key: DynamicValue) — value at a specific map key
├── AtMapKeys(keys: Seq[DynamicValue]) — values at multiple map keys
├── Elements — all elements in a sequence (wildcard)
├── MapKeys — all keys in a map (wildcard)
├── MapValues — all values in a map (wildcard)
└── Wrapped — inner value of a wrapper/newtype
Each of the DynamicOptic nodes represents a different way to navigate through a data structure. Here's a reference table for the nodes, their navigation semantics, and string representations:
| Node | Navigates | toString | toScalaString |
|---|---|---|---|
Field("name") | Record field | .name | .name |
Case("Email") | Variant case | <Email> | .when[Email] |
AtIndex(0) | Sequence element | [0] | .at(0) |
AtIndices(Seq(0,2)) | Multiple elements | [0,2] | .atIndices(0, 2) |
AtMapKey(k) | Map entry by key | {"host"} | .atKey("host") |
AtMapKeys(ks) | Multiple entries | {"a", "b"} | .atKeys("a", "b") |
Elements | All sequence elements | [*] | .each |
MapKeys | All map keys | {*:} | .eachKey |
MapValues | All map values | {*} | .eachValue |
Wrapped | Wrapper inner value | .~ | .wrapped |
Key design decisions:
- Map keys as
DynamicValue— Map keys can be any type (String,Int,Boolean, etc.), soAtMapKeystores the key as aDynamicValueto remain type-agnostic. - Dual rendering —
toStringproduces a compact interpolator syntax (.field[0]{key}), whiletoScalaStringproduces Scala method-call syntax (.field.at(0).atKey(key)). The compact format is designed to be copy-pasteable into thep"..."string interpolator, while the Scala format is used in error messages. - Every typed
Optichas atoDynamicmethod — This bridges the typed and untyped worlds, allowing anyLens,Prism,Optional, orTraversalto produce itsDynamicOpticrepresentation.
Construction
Starting Points
Every DynamicOptic starts from root (the identity/empty path) or one of the pre-built singletons:
import zio.blocks.schema.DynamicOptic
val root = DynamicOptic.root // empty path: "."
val elems = DynamicOptic.elements // "[*]"
val keys = DynamicOptic.mapKeys // "{*:}"
val values = DynamicOptic.mapValues // "{*}"
val inner = DynamicOptic.wrapped // ".~"
Builder Methods
Chain builder methods on DynamicOptic.root (or any existing optic) to construct paths fluently:
import zio.blocks.schema._
// Navigate into a record field, then a sequence index, then another field
val path = DynamicOptic.root.field("users").at(0).field("name")
// toString: .users[0].name
// Navigate into a map with a typed key
val configPath = DynamicOptic.root.field("config").atKey("host")
// toString: .config{"host"}
// Navigate into a variant case, then a field
val resultPath = DynamicOptic.root.field("result").caseOf("Success").field("value")
// toString: .result<Success>.value
// Select all elements, then a field on each
val allEmails = DynamicOptic.root.field("users").elements.field("email")
// toString: .users[*].email
| Method | Node Produced | Example |
|---|---|---|
.field(name) | Field(name) | .field("street") |
.caseOf(name) | Case(name) | .caseOf("Email") |
.at(index) | AtIndex(index) | .at(0) |
.atIndices(i*) | AtIndices(indices) | .atIndices(0, 2, 5) |
.atKey[K](key) | AtMapKey(dv) | .atKey("host") |
.atKeys[K](k*) | AtMapKeys(dvs) | .atKeys("a", "b") |
.elements | Elements | .elements |
.mapKeys | MapKeys | .mapKeys |
.mapValues | MapValues | .mapValues |
.wrapped | Wrapped | .wrapped |
Note: .atKey and .atKeys require an implicit Schema[K] to convert the typed key to a DynamicValue.
Path Interpolator
The p"..." path interpolator provides a concise compile-time syntax for building
DynamicOptic values:
import zio.blocks.schema._
// Equivalent builder vs interpolator
val builderPath : DynamicOptic = DynamicOptic.root.field("users").at(0).field("name")
val interpolatorPath: DynamicOptic = p".users[0].name"
// Both produce the same DynamicOptic
// Wildcards
val allEmails = p".users[*].email"
// Map access
val host = p""".config{"host"}"""
// Variant cases
val success = p".result<Success>.value"
// Complex path
val complex = p""".groups[*].members[0].contacts{"email"}"""
See Path Interpolator for the full syntax reference.
From Typed Optics
Every typed Optic[S, A] can be converted to a DynamicOptic via toDynamic:
import zio.blocks.schema._
case class Address(street: String, city: String)
object Address {
implicit val schema: Schema[Address] = Schema.derived
}
case class Person(name: String, address: Address)
object Person extends CompanionOptics[Person] {
implicit val schema: Schema[Person] = Schema.derived
val name: Lens[Person, String] = optic(_.name)
val street: Lens[Person, String] = optic(_.address.street)
}
val dynamicName: DynamicOptic = Person.name.toDynamic
// toString: .name
val dynamicStreet: DynamicOptic = Person.street.toDynamic
// toString: .address.street
See Optics for more on typed optics.
Operations
Composition
DynamicOptic values compose via the apply method, which concatenates their node sequences:
import zio.blocks.schema._
val users = DynamicOptic.root.field("users")
val first = DynamicOptic.root.at(0)
val name = DynamicOptic.root.field("name")
// Compose three paths into one
val fullPath = users(first)(name)
// toString: .users[0].name
// Compose with pre-built singletons
val allUserNames = users(DynamicOptic.elements)(name)
// toString: .users[*].name
// Compose with interpolator-built paths
val emails = users(p"[*].email")
// toString: .users[*].email
DynamicValue Operations
DynamicOptic is the path argument for all DynamicValue operations: get(path) for retrieval (with .one or .toChunk), modify(path)(f) for transformation, set(path, value) for replacement, delete(path) for removal, and insert(path, value) for addition. By default, the mutating operations (modify, set, delete, insert) are lenient and return the original value unchanged if the path doesn't resolve, whereas get(path) yields a failing DynamicValueSelection on a missing path (though calling .toChunk on it will produce an empty chunk). Each operation also has a strict *OrFail variant returning Either[SchemaError, DynamicValue] with error details on failure.
For example, the DynamicValue#get method uses DynamicOptic to navigate and extract values:
import zio.blocks.schema._
val data = DynamicValue.Record(
"users" -> DynamicValue.Sequence(
DynamicValue.Record("name" -> DynamicValue.string("Alice"), "age" -> DynamicValue.int(30)),
DynamicValue.Record("name" -> DynamicValue.string("Bob"), "age" -> DynamicValue.int(25))
)
)
// Get a single value
val firstName = data.get(p".users[0].name").one
// Right(DynamicValue.Primitive(PrimitiveValue.String("Alice")))
// Get all matching values (wildcard)
val allNames = data.get(p".users[*].name").toChunk
// Chunk(DynamicValue.string("Alice"), DynamicValue.string("Bob"))
Schema & Reflect Operations
DynamicOptic can navigate schema structures, not just values. This is useful for schema introspection and
metaprogramming.
- The
Schema#getmethod takes aDynamicOpticpath and returns theReflectfor the nested component at that path, if it exists. This allows you to programmatically explore the structure of a schema:
import zio.blocks.schema._
case class Address(street: String, city: String)
object Address {
implicit val schema: Schema[Address] = Schema.derived
}
case class Person(name: String, address: Address)
object Person {
implicit val schema: Schema[Person] = Schema.derived
}
// Get the Reflect for the "street" field inside Person
val streetReflect: Option[Reflect.Bound[?]] =
Schema[Person].get(p".address.street")
- The
DynamicSchema#getmethod works similarly, allowing you to navigate aDynamicSchemastructure:
import zio.blocks.schema._
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived
}
val dynSchema = Schema[Person].toDynamicSchema
val nameReflect: Option[Reflect.Unbound[_]] =
dynSchema.get(p".name")
- By applying a
DynamicOpticdirectly to aReflectvalue, you can navigate the reflected structure of a type:
import zio.blocks.schema._
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived
}
val reflect = Schema[Person].reflect
val path = p".name"
// Using the DynamicOptic's apply method
val result: Option[Reflect[?, ?]] = path(reflect)
Failure Path in OpticCheck
Typed optics use DynamicOptic internally for error reporting. When a typed optic operation fails (e.g., a Prism encounters the wrong case), the error includes the DynamicOptic path to pinpoint exactly where the failure occurred.
OpticCheck(errors: ::[Single])
└── Single (sealed trait)
├── Error (sealed trait)
│ ├── UnexpectedCase — prism matched wrong variant case
│ └── WrappingError — wrapper conversion failed
└── Warning (sealed trait)
├── EmptySequence — traversal over empty sequence
├── SequenceIndexOutOfBounds — index beyond sequence length
├── MissingKey — map key not found
└── EmptyMap — traversal over empty map
Every OpticCheck.Single carries two DynamicOptic paths:
full— The complete optic path that was being evaluatedprefix— The path up to the point where the error occurred
Error messages use toScalaString for human-readable output:
During attempted access at .when[Email].subject,
encountered an unexpected case at .when[Email]:
expected Email, but got Push
Path String Syntax
DynamicOptic provides two string formats for different contexts:
toString— Compact path syntax matching thep"..."interpolator formattoScalaString— Scala method call syntax used in error messages
| Node | toString | toScalaString |
|---|---|---|
Field("name") | .name | .name |
Case("Email") | <Email> | .when[Email] |
AtIndex(0) | [0] | .at(0) |
AtIndices(Seq(0, 2)) | [0,2] | .atIndices(0, 2) |
AtMapKey(string "host") | {"host"} | .atKey("host") |
AtMapKey(int 42) | {42} | .atKey(42) |
AtMapKey(bool true) | {true} | .atKey(true) |
AtMapKey(char 'a') | {'a'} | .atKey('a') |
AtMapKeys(strings) | {"a", "b"} | .atKeys("a", "b") |
Elements | [*] | .each |
MapKeys | {*:} | .eachKey |
MapValues | {*} | .eachValue |
Wrapped | .~ | .wrapped |
| root | . | . |
Serialization
DynamicOptic has an implicit Schema[DynamicOptic] defined in its companion object, which means it can be serialized and deserialized just like any other schema-equipped type. This enables storing optic paths in databases, sending them over the wire, or including them in configuration files:
import zio.blocks.schema._
// Schema[DynamicOptic] is available implicitly
val opticSchema: Schema[DynamicOptic] = Schema[DynamicOptic]
// Convert to/from DynamicValue for serialization
val path = p".users[0].name"
val serialized: DynamicValue = opticSchema.toDynamicValue(path)