| id | path-codec |
|---|---|
| title | PathCodec |
PathCodec[A] is a composable descriptor for URL path structures. It holds a tree of segment codecs connected by concatenation and fallback nodes, and provides bidirectional path conversion: PathCodec#decode extracts a typed value from a Path, returning Either[String, A] (the typed value or error), and PathCodec#format formats a typed value back to a Path, returning Either[String, Path] (the path or error). In addition to its runtime value type A, each codec also carries a phantom PathVars track that records the ordered list of declared path-variable markers contributed by its dynamic segments. Its definition begins:
sealed trait PathCodec[A] {
type PathVars
}PathVars is purely type-level: it has zero runtime footprint and does not affect decoding or formatting. It exists so downstream tooling (for example, handler macros or static checks) can recover which named path variables a route declared, in order, and whether any of them were explicitly marked as ignored.
URL paths need both matching and generation. A routing library that only matches paths requires a separate URL-builder for links and redirects, leading to duplication and drift. PathCodec is bidirectional: every codec that can decode /users/42 into 42: Int can also format 42 back into /users/42. This makes it safe to use the same path definition for routing, link generation, and OpenAPI path parameter documentation.
PathCodec is an ADT with four node types:
| Node | Meaning |
|---|---|
Segment |
A single path segment, described by a SegmentCodec[A] |
Concat |
Two path codecs composed sequentially with / or ++ |
Transform |
Bidirectional type mapping over an existing codec |
Fallback |
Two literal alternatives (applies only with orElse) |
We build a PathCodec from smart constructors that produce typed segment nodes, from a path string, or by wrapping a SegmentCodec directly.
The most common path building blocks are available as smart constructors on the companion:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val literalUsers: PathCodec[Unit] = PathCodec.literal("users")
val intId: PathCodec[Int] = PathCodec.int("id")
val longId: PathCodec[Long] = PathCodec.long("id")
val stringSlug: PathCodec[String] = PathCodec.string("slug")
val uuidId: PathCodec[java.util.UUID] = PathCodec.uuid("id")
val boolFlag: PathCodec[Boolean] = PathCodec.bool("enabled")
val rest: PathCodec[zio.http.Path] = PathCodec.trailingPathCodec.literal is a macro that validates the value at compile time — it rejects empty strings and strings containing / or characters requiring URL encoding.
For literal names like PathCodec.int("id"), the name is preserved as a singleton type inside PathVars, so the phantom track remembers not just that the codec captures an Int, but that it came from the path variable named "id".
To build a codec from a slash-separated string of literal segments, use PathCodec.apply:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val path: PathCodec[Unit] = PathCodec("/api/v1/users")This is equivalent to concatenating PathCodec.literal for each segment.
To wrap a custom SegmentCodec[A] into a PathCodec[A], use PathCodec.apply(segment):
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val combined = PathCodec(SegmentCodec.literal("v") ~ SegmentCodec.int("version"))There is also an implicit conversion from SegmentCodec[A] to PathCodec[A] and from String to PathCodec[Unit], so both can appear directly in / expressions.
Every dynamic path segment contributes one phantom marker to PathCodec#PathVars:
PathCodec.int("id")contributesPathVar["id", Int]PathCodec.uuid("orderId")contributesPathVar["orderId", UUID]- literal segments and
PathCodec.trailingcontribute no markers
Sequential composition with / or ++ concatenates those markers in declaration order, matching the left-to-right route shape.
Sometimes a route needs to capture a segment for matching or formatting, but a downstream handler intentionally does not consume that variable. For that case, single-variable codecs expose .unused, which keeps the runtime behavior identical while relabeling the phantom marker to PathVar.Ignored[Name, Type]:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val userId: PathCodec[Int] = PathCodec.int("id")
val ignoredUserId: PathCodec[Int] = PathCodec.int("id").unused.unused has zero runtime cost: decoding, formatting, rendering, and matching all behave exactly the same as the non-.unused codec. The only difference is the phantom PathVars marker, which tells tooling that this declared path variable was intentionally ignored.
Path codecs compose in two ways: sequential concatenation with / or ++, and literal alternatives with orElse.
/ and ++ are equivalent: both concatenate two path codecs. The result type is flattened automatically (so Unit / Int gives Int, not (Unit, Int)), eliminating Unit components:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val route: PathCodec[Int] = PathCodec.literal("users") / PathCodec.int("id")In the context of a RoutePattern, the same operator works directly:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
import zio.http.Method
val pattern = Method.GET / "users" / PathCodec.int("id") / "posts"To match either of two literal segments, use orElse:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val either: PathCodec[Unit] =
PathCodec.literal("users").orElse(PathCodec.literal("members"))orElse is for literal alternatives only. Both branches must be PathCodec[Unit] with no captured path variables, and PathCodec#alternatives still validates at runtime that the branches are genuinely literal-only. In practice, use orElse only with PathCodec.literal(...) or string-literal path codecs.
PathCodec is bidirectional: PathCodec#decode turns a runtime Path into a typed value, and PathCodec#format turns a typed value back into a Path.
To extract a typed value from a runtime Path:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
import zio.http.Path
val codec = PathCodec.int("id")
val result: Either[String, Int] = codec.decode(Path("/42"))PathCodec#decode returns Left(message) when no segment matches or a segment cannot be parsed.
To turn a typed value into a Path:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
import zio.http.Path
val codec = PathCodec.uuid("id")
val path: Either[String, Path] =
codec.format(java.util.UUID.fromString("550e8400-e29b-41d4-a716-446655440000"))To test whether a Path matches without extracting a value:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
import zio.http.Path
val codec = PathCodec.literal("users")
val matched = codec.matches(Path("/users"))Use these methods to map the typed value that PathCodec decodes or encodes without changing the underlying path structure.
To map the decoded value to a different type without changing the path structure, use PathCodec#transform. Both directions must be total:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
import zio.blocks.endpoint.PathCodec._
final case class UserId(value: Int)
val userIdCodec: PathCodec[UserId] =
PathCodec.int("id").transform[UserId](UserId(_), _.value)When decoding or encoding can fail, use PathCodec#transformOrFail. A Left from the decode function causes the path not to match:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val nonNegativeInt: PathCodec[Int] =
PathCodec.int("count").transformOrFail[Int](
n => if (n >= 0) Right(n) else Left(s"Expected non-negative, got $n"),
n => Right(n)
)PathCodec#render produces a human-readable path string. Dynamic segments appear as {name} by default:
import zio.blocks.endpoint._
import zio.blocks.endpoint.RoutePattern._
val codec = PathCodec.literal("users") / PathCodec.int("id")
val rendered: String = codec.renderTo use a different prefix/suffix for dynamic segments (e.g., :id for Express-style paths), call the lower-level PathCodec.render(codec, prefix = ":", suffix = "").