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.
Overviewβ
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)
)
// ctx: Context[Config & Logger & Metrics] = Context(repl.MdocSession.MdocApp.Metrics -> Metrics(42), repl.MdocSession.MdocApp.Logger -> Logger(app), repl.MdocSession.MdocApp.Config -> Config(true))
val config: Config = ctx.get[Config]
// config: Config = Config(true)
val logger: Logger = ctx.get[Logger]
// logger: Logger = Logger("app")
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) β
βββββββββββββββββββββββββββββββββββ
Motivationβ
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 missing
ZIO'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 exists
Installationβ
Add the ZIO Blocks Context module to your build.sbt:
libraryDependencies += "dev.zio" %% "zio-blocks-context" % "0.0.33"
Constructionβ
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.
Creating Empty Contextsβ
Use Context.empty to create an empty context with no entries:
import zio.blocks.context._
val emptyCtx: Context[Any] = Context.empty
// emptyCtx: Context[Any] = Context()
val isEmpty = emptyCtx.isEmpty
// isEmpty: Boolean = true
An empty context has type Context[Any] and represents no stored dependencies. This is a useful starting point for incremental construction.
Creating Multi-Value Contexts with Context.applyβ
Context.apply is overloaded to accept 1β10 values and returns a context with type Context[A1 & A2 & ...], reflecting all stored types.
Single Valueβ
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))
// single: Context[Config] = Context(repl.MdocSession.MdocApp1.Config -> Config(true))
Multiple Valuesβ
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")
)
// multi: Context[Config & Logger] = Context(repl.MdocSession.MdocApp1.Logger -> Logger(myapp), repl.MdocSession.MdocApp1.Config -> Config(true))
Building Incrementally with Context#addβ
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"))
// ctx: Context[Config & Logger] = Context(repl.MdocSession.MdocApp1.Logger -> Logger(init), repl.MdocSession.MdocApp1.Config -> Config(false))
The context accumulates all added entries:
val size1 = ctx.size
// size1: Int = 2
When 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
Core Operationsβ
Context supports inspection, retrieval, and modification operations. All methods are type-safe and leverage the phantom type R to track what's stored.
Inspectionβ
The following methods let you check the contents of a context without retrieving specific values:
Context#sizeβ
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.size
// sz: Int = 2
Context#isEmptyβ
Returns 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
// e1: Boolean = true
val e2 = notEmpty.isEmpty
// e2: Boolean = false
Context#nonEmptyβ
Returns 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
// e1: Boolean = false
val e2 = notEmpty.nonEmpty
// e2: Boolean = true
Retrievalβ
The following methods let you retrieve values from a context by type:
Context#getβ
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 type
Context#getOptionβ
Retrieves 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]
// found: Option[Config] = Some(Config(true))
val missing: Option[String] = ctx.getOption[String]
// missing: Option[String] = None
Modificationβ
All modification methods return a new Contextβthe original remains immutable:
Context#addβ
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]
Context#updateβ
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].count
Context#++ (Union)β
Combines 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 ++ right
Context#pruneβ
Narrows 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.size
Context#toStringβ
Returns 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.toString
// str: String = "Context(repl.MdocSession.MdocApp1.Logger -> Logger(app), repl.MdocSession.MdocApp1.Config -> Config(true))"
Covarianceβ
Context 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.
Type Safety: IsNominalTypeβ
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 type
Performanceβ
Caching: 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.
Comparing Approachesβ
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 | β | β | β |
Integration with Wire and Scopeβ
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)
}
Running the Examplesβ
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-blocks
2. Run individual examples with sbt:
Context construction: creating contexts with apply, empty.add, and inspecting size/isEmpty/nonEmpty
/*
* Copyright 2024-2026 John A. De Goes and the ZIO Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import zio.blocks.context._
import util.ShowExpr.show
// Context.empty creates an empty, type-safe dependency container.
// Use Context.apply(...) to construct contexts with 1β10 values.
// The phantom type parameter tracks which types are present.
object ContextConstructionExample extends App {
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
// Start with an empty context.
show(Context.empty.size)
show(Context.empty.isEmpty)
// Create a context with one value.
val ctx1 = Context(Config(debug = true))
show(ctx1.size)
show(ctx1.nonEmpty)
// Create a context with multiple values (up to 10 supported).
val ctx2 = Context(
Config(debug = false),
Logger("myapp")
)
show(ctx2.size)
show(ctx2.isEmpty)
// Create a larger context.
val ctx3 = Context(
Config(debug = true),
Logger("prod"),
Metrics(count = 100)
)
show(ctx3.size)
// Build incrementally from empty using add.
val ctxBuilt = Context.empty
.add(Config(debug = false))
.add(Logger("init"))
.add(Metrics(count = 0))
show(ctxBuilt.size)
show(ctxBuilt.nonEmpty)
// toString shows the context contents in a readable format.
show(ctx3.toString)
}
(source)
sbt "schema-examples/runMain context.ContextConstructionExample"
Context retrieval: using get, supertype lookups, and getOption for safe access
/*
* Copyright 2024-2026 John A. De Goes and the ZIO Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import zio.blocks.context._
import util.ShowExpr.show
// Context#get[A] retrieves a value by type with compile-time proof of existence.
// Context#getOption[A] retrieves a value if present, returning None if missing.
// Supertype matching allows retrieving a subtype using a more general supertype.
object ContextRetrievalExample extends App {
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
trait Animal { def sound: String }
case class Dog(name: String) extends Animal {
def sound = "Woof"
}
// Create a context with multiple values.
val ctx = Context(
Config(debug = true),
Logger("app"),
Metrics(count = 42)
)
// Retrieve by exact type.
val config: Config = ctx.get[Config]
show(config)
val logger: Logger = ctx.get[Logger]
show(logger)
val metrics: Metrics = ctx.get[Metrics]
show(metrics)
// getOption returns Some if the type is present.
val configOpt: Option[Config] = ctx.getOption[Config]
show(configOpt)
// getOption returns None if the type is missing (safe for optional lookups).
val missingOpt: Option[String] = ctx.getOption[String]
show(missingOpt)
// Supertype matching: retrieve a subtype using a supertype.
val dogCtx = Context(Dog("Buddy"))
val animal: Animal = dogCtx.get[Animal]
show(animal.sound)
// getOption also supports supertype matching.
val animalOpt: Option[Animal] = dogCtx.getOption[Animal]
show(animalOpt.map(_.sound))
// Cache efficiency: repeated lookups return the cached instance.
val first = ctx.get[Logger]
val second = ctx.get[Logger]
show(first eq second)
}
(source)
sbt "schema-examples/runMain context.ContextRetrievalExample"
Context modification: adding values, updating existing ones, merging contexts, and pruning types
/*
* Copyright 2024-2026 John A. De Goes and the ZIO Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package context
import zio.blocks.context._
import util.ShowExpr.show
// Context is immutable; modification methods return new contexts.
// Context#add expands the context with a new value (or replaces if type exists).
// Context#update transforms a value if present.
// Context#++ merges two contexts (right side wins on conflict).
// Context#prune narrows to specific types.
object ContextModificationExample extends App {
case class Config(debug: Boolean)
case class Logger(name: String)
case class Metrics(count: Int)
// Start with a simple context.
val ctx1 = Context(Config(debug = false))
show(ctx1.size)
// add expands the context with a new value.
val ctx2 = ctx1.add(Logger("app"))
show(ctx2.size)
show(ctx2.get[Config])
show(ctx2.get[Logger])
// add replaces if the type already exists.
val ctx3 = ctx2.add(Config(debug = true))
show(ctx3.size)
show(ctx3.get[Config].debug)
// update transforms a value if present.
val ctx4 = ctx3.add(Metrics(count = 100))
val updated = ctx4.update[Metrics](m => m.copy(count = m.count + 50))
show(updated.get[Metrics].count)
// update also works on existing types.
val configUpdated = updated.update[Config](c => c.copy(debug = false))
show(configUpdated.get[Config])
// ++ merges two contexts; right side wins on conflict.
val left = Context(Config(debug = false), Logger("left"))
val right = Context(Config(debug = true), Metrics(count = 99))
val merged = left ++ right
show(merged.size)
show(merged.get[Config].debug)
show(merged.get[Logger].name)
show(merged.get[Metrics].count)
// prune narrows a context to specific types.
val full = Context(Config(true), Logger("app"), Metrics(100))
show(full.size)
val justConfig = full.prune[Config]
show(justConfig.size)
show(justConfig.getOption[Logger])
// Chaining modifications.
val chain = Context.empty
.add(Config(debug = true))
.add(Logger("chain"))
.add(Metrics(count = 0))
.update[Metrics](m => m.copy(count = 42))
show(chain.size)
show(chain.get[Metrics].count)
}
(source)
sbt "schema-examples/runMain context.ContextModificationExample"