| id | context |
|---|---|
| title | Context |
Context[+R] is a type-indexed heterogeneous collection that stores values of different types, indexed by their types, with compile-time type safety for lookups. It provides an immutable, cache-aware dependency container where the phantom type R (using intersection types) tracks which types are present.
The core type looks like this:
// Signature (showing public API structure, not actual implementation)
final class Context[+R] {
def size: Int
def isEmpty: Boolean
def nonEmpty: Boolean
def get[A >: R](implicit ev: IsNominalType[A]): A
def getOption[A](implicit ev: IsNominalType[A]): Option[A]
def add[A](a: A)(implicit ev: IsNominalType[A]): Context[R & A]
def update[A >: R](f: A => A)(implicit ev: IsNominalType[A]): Context[R]
def ++[R1](that: Context[R1]): Context[R & R1]
def prune[A >: R](implicit ev: IsNominalIntersection[A]): Context[A]
override def toString: String
}Key properties: covariant (+R), immutable, cached for repeated lookups, supports only nominal types.
Context serves as a type-safe registry for heterogeneous dependencies. Here's a quick example:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)We can create and retrieve values by type:
val ctx: Context[Config & Logger & Metrics] = Context(
Config(debug = true),
Logger("app"),
Metrics(count = 42)
)
val config: Config = ctx.get[Config]
val logger: Logger = ctx.get[Logger]This ASCII diagram shows how Context maps types to values:
┌─────────────────────────────────┐
│ Context[R] │
│ (Type-Indexed Store) │
├─────────────────────────────────┤
│ Config → Config(true) │
│ Logger → Logger("app") │
│ Metrics → Metrics(42) │
├─────────────────────────────────┤
│ ✓ Type-safe retrieval │
│ ✓ No casting │
│ ✓ Cache-aware (O(1) repeats) │
└─────────────────────────────────┘
When building modular applications, we often need to pass multiple dependencies around—a database connection, a config object, a logger, and so on. Existing approaches each have limitations:
Map[Class[_], Any] — no compile-time safety. You must cast the result and remember which keys you registered:
// Unsafe approach — easy to make mistakes
val deps = scala.collection.mutable.Map[Class[_], Any]()
deps(classOf[Config]) = Config(debug = true)
deps(classOf[Logger]) = Logger("app")
val config = deps(classOf[Config]).asInstanceOf[Config] // Manual cast
val db = deps(classOf[Database]) // Runtime error if missingZIO's ZEnvironment — type-safe but requires the full ZIO effect system:
// Requires ZIO context
import zio._
val makeEnv = for {
config <- ZIO.service[Config]
logger <- ZIO.service[Logger]
} yield (config, logger)Context — combines compile-time type safety with synchronous, pure code:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
// Type-safe, no effects needed
val ctx = Context(Config(true), Logger("app"))
val config = ctx.get[Config] // Compile-time proof it existsAdd the ZIO Blocks Context module to your build.sbt:
libraryDependencies += "dev.zio" %% "zio-blocks-context" % "@VERSION@"Context provides several ways to create instances. Choose the approach that best fits your use case: start empty and add values incrementally, or construct a fully-populated context directly with apply.
Use Context.empty to create an empty context with no entries:
import zio.blocks.context._
val emptyCtx: Context[Any] = Context.empty
val isEmpty = emptyCtx.isEmptyAn empty context has type Context[Any] and represents no stored dependencies. This is a useful starting point for incremental construction.
Context.apply is overloaded to accept 1–10 values and returns a context with type Context[A1 & A2 & ...], reflecting all stored types.
Create a context with one value:
case class Config(debug: Boolean)With Config defined, we can create a single-value context:
val single: Context[Config] = Context(Config(debug = true))Create a context with multiple values—the type parameter automatically becomes an intersection of all stored types:
case class Logger(name: String)With Config and Logger in scope, we can create a multi-value context:
val multi: Context[Config & Logger] = Context(
Config(debug = true),
Logger("myapp")
)For contexts that grow over time, use Context#add to build incrementally from an empty context. This is useful when dependencies become available at different points in your initialization:
val ctx = Context.empty
.add(Config(debug = false))
.add(Logger("init"))The context accumulates all added entries:
val size1 = ctx.sizeWhen to use Context#add vs. Context.apply:
- Use
Context.applywhen you know all dependencies upfront and can construct them together - Use
Context#addwhen dependencies are added incrementally or conditionally
Context supports inspection, retrieval, and modification operations. All methods are type-safe and leverage the phantom type R to track what's stored.
The following methods let you check the contents of a context without retrieving specific values:
Returns the number of entries in the context:
case class Config(debug: Boolean)
case class Logger(name: String)
val ctx = Context(Config(true), Logger("app"))Get the number of entries in the context:
val sz = ctx.sizeReturns true if the context contains no entries:
case class Config(debug: Boolean)
val empty = Context.empty
val notEmpty = Context(Config(true))Now check the isEmpty status of both contexts:
val e1 = empty.isEmpty
val e2 = notEmpty.isEmptyReturns true if the context contains at least one entry (opposite of isEmpty):
case class Config(debug: Boolean)
val empty = Context.empty
val notEmpty = Context(Config(true))Check the nonEmpty status of both contexts:
val e1 = empty.nonEmpty
val e2 = notEmpty.nonEmptyThe following methods let you retrieve values from a context by type:
Retrieves a value by type. The type bound A >: R ensures that a value of type A (or a subtype of A) is present at compile time:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
val ctx = Context(Config(debug = true), Logger("app"), Metrics(100))
// Retrieve by exact type
val config = ctx.get[Config]Or by supertype (subtype matching):
import zio.blocks.context._
trait Animal { def sound: String }
case class Dog(name: String) extends Animal {
def sound = "Woof"
}
val ctxDog = Context(Dog("Buddy"))
val animal = ctxDog.get[Animal]If you attempt to retrieve a type that is not in the context, the code will not compile because the type bound A >: R requires it to be present:
// This is a compile-time error, not a runtime error:
// val metrics: String = ctx.get[String] // Error: String is not in context typeRetrieves a value if present, returning Option[A]. Unlike get, this method does not require the type to be in the context's type parameter. Use it for optional lookups:
case class Config(debug: Boolean)
val ctx = Context(Config(debug = true))Try to retrieve both an existing type and a missing type:
val found: Option[Config] = ctx.getOption[Config]
val missing: Option[String] = ctx.getOption[String]All modification methods return a new Context—the original remains immutable:
Adds a value to the context, expanding the phantom type by & A:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
val ctx1 = Context(Config(true))
val ctx2 = ctx1.add(Logger("new"))If a value of the same type already exists, it is replaced:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
val ctx1 = Context(Config(true))
val ctx2 = ctx1.add(Logger("new"))
val ctx3 = ctx2.add(Config(debug = false))
val replaced = ctx3.get[Config]Transforms an existing value if it is present. If the type is not found, the context is returned unchanged:
import zio.blocks.context._
case class Metrics(count: Int)
val ctx = Context(Metrics(count = 10))
val updated = ctx.update[Metrics](m => m.copy(count = m.count + 5))
val newCount = updated.get[Metrics].countCombines two contexts into a new context containing all entries. When both contexts contain the same type, the value from the right side (second argument) wins:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
val left = Context(Config(debug = false), Logger("left"))
val right = Context(Config(debug = true), Metrics(99))
val merged = left ++ rightNarrows a context to contain only specified types. All other entries are discarded:
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
val full = Context(Config(true), Logger("app"), Metrics(100))
val justConfig = full.prune[Config]
val configSize = justConfig.sizeReturns a human-readable representation of the context showing all type-value pairs:
case class Config(debug: Boolean)
case class Logger(name: String)
val ctx = Context(Config(debug = true), Logger("app"))Convert the context to a human-readable string representation:
val str = ctx.toStringContext is covariant in its type parameter, meaning Context[Dog] <: Context[Animal] when Dog <: Animal. This allows passing a more-specific context to code expecting a more-general one:
import zio.blocks.context._
trait Animal { def sound: String }
case class Dog(name: String) extends Animal {
def sound = "Woof"
}
def processAnimal(ctx: Context[Animal]): String = ctx.get[Animal].sound
val dogCtx = Context(Dog("Buddy"))
val sound = processAnimal(dogCtx)Covariance also applies during retrieval—if you request a supertype, the stored subtype is returned.
Context only accepts nominal types—concrete classes, case classes, traits, and objects. The compiler automatically derives IsNominalType[A] for allowed types and rejects unsupported kinds:
Supported:
- Classes:
case class Config(...) - Traits:
trait Logger - Objects:
object Registry - Applied types:
List[Int],Map[String, Int] - Enums (Scala 3):
enum Color { case Red, Green, Blue }
Not supported (compile error):
- Intersection types:
A & B(use the context type parameter instead) - Union types:
A | B - Structural types:
{ def foo: Int }
Attempting to store an unsupported type:
// This fails at compile time:
// Context.empty.add(null: (String & Int)) // Error: unsupported typeCaching: When a value is retrieved via get or getOption, the result is cached. Repeated lookups for the same type return the cached value in O(1) time without traversing the entries again.
Subtype matching: Supertype lookups scan the entry list linearly to find a compatible subtype. After the first lookup, the result is cached, so subsequent requests for that supertype are O(1).
Platform optimizations: The JVM implementation uses ConcurrentHashMap for thread-safe caching. The JavaScript platform uses a simpler in-memory mutable hash map for efficient lookups.
Here is a comparison of Context with related alternatives:
| Feature | Map[Class[_], Any] |
ZEnvironment |
Context |
|---|---|---|---|
| Type-safe retrieval | ✗ (cast required) | ✓ | ✓ |
| Compile-time proof | ✗ | ✓ | ✓ |
| Effect-free | ✓ | ✗ (requires ZIO) | ✓ |
| Immutable | ✓ | ✓ | ✓ |
| Cached lookups | ✗ | ✓ | ✓ |
| Supertype matching | ✗ | ✓ | ✓ |
Context is the dependency carrier in ZIO Blocks' Wire-based dependency injection system. A Wire[-In, +Out] describes how to build an output given input dependencies, and contexts supply those dependencies. Wire and Scope work together to provide type-safe, compile-checked dependency injection:
// Pseudocode illustrating how Context integrates with Wire and Scope
import zio.blocks.scope._
import zio.blocks.context._
case class Config(debug: Boolean)
case class Logger(name: String)
case class Service(config: Config, logger: Logger)
// Define a wire that requires Config and Logger to build Service
val buildService = Wire.make[Config & Logger, Service]
// Create a context with the required dependencies
val deps = Context(Config(debug = true), Logger("app"))
// Create a scope and instantiate the service
Scope.global.scoped { scope =>
val service: Service = buildService.make(scope, deps)
}All code from this guide is available as runnable examples in the schema-examples module.
1. Clone the repository and navigate to the project:
git clone https://github.com/zio/zio-blocks.git
cd zio-blocks2. Run individual examples with sbt:
Context construction: creating contexts with apply, empty.add, and inspecting size/isEmpty/nonEmpty
import docs.SourceFile
SourceFile.print("schema-examples/src/main/scala/context/ContextConstructionExample.scala")(source)
sbt "schema-examples/runMain context.ContextConstructionExample"Context retrieval: using get, supertype lookups, and getOption for safe access
import docs.SourceFile
SourceFile.print("schema-examples/src/main/scala/context/ContextRetrievalExample.scala")(source)
sbt "schema-examples/runMain context.ContextRetrievalExample"Context modification: adding values, updating existing ones, merging contexts, and pruning types
import docs.SourceFile
SourceFile.print("schema-examples/src/main/scala/context/ContextModificationExample.scala")(source)
sbt "schema-examples/runMain context.ContextModificationExample"