Scala 2 Compatibility Design Note
This document explains the design of Scala 2.13 support for zio.blocks.streams and the constraints that shaped it.
Motivation​
HTTP data types in zio-blocks depend on streams. zio-http 4 depends on those HTTP data types. Without Scala 2 stream support, zio-http cannot offer Scala 2 support. That dependency chain makes Scala 2.13 support for streams a hard requirement, not an optional nicety.
Non-negotiable constraint: Scala 3 performance​
The streams implementation uses Scala 3 features on hot combinator paths, especially in Stream and Sink methods that participate in specialization, error-channel elimination, and zero-boxing-friendly code generation. The inline keyword on performance-sensitive helpers is not cosmetic; it directly affects what the JVM sees.
A Scala 2 compatibility layer is only acceptable if it leaves the Scala 3 hot path structurally unchanged. Concretely, this rules out:
- Moving key instance methods out of the Scala 3 class body into a shared trait
- Removing or weakening
inlinedefinitions to satisfy Scala 2's lack of that feature - Introducing extra trait boundaries that alter the generated Scala 3 bytecode
Adapting surface syntax from Scala 3 using to Scala 2 implicit is fine where it does not touch the hot path. The risk is not the spelling of contextual parameters; the risk is changing the runtime shape of Stream and Sink under Scala 3.
Rejected approach: version-specific trait extraction​
An early draft extracted several instance methods into StreamVersionSpecific and SinkVersionSpecific traits under scala-2/ and scala-3/, with the shared classes extending those traits. The methods moved or routed through these traits included:
Stream.++,Stream.catchAll,Stream.catchDefect,Stream.concatStream.flatMap,Stream.mapError,Stream.orElse,Stream.&&Sink.mapError
This shape localized the syntax differences neatly, but changed the structure of the Scala 3 hot path enough to produce measurable regressions. Benchmarks run with streams-benchmark on Scala 3.8.3 and JDK 25:
| Benchmark | Baseline (main) | Trait-extraction draft | Change |
|---|---|---|---|
StreamPipelineBench.zb_flatMap | 14924.328 ops/s | 1178.997 ops/s | ~12x regression |
StreamPipelineBench.zb_concat | 26003.818 ops/s | 22044.497 ops/s | regression |
StreamPipelineBench.zb_filterMap | 54812.709 ops/s | 50349.471 ops/s | regression |
The flatMap result is the clearest signal. Dropping from roughly 14.9k ops/s to 1.18k ops/s is not an acceptable tradeoff for any compatibility layer. The approach was rejected.
Chosen approach: full per-version split​
The implementation uses a full per-version source split. Stream, Sink, Reader, Writer, Pipeline, and Interpreter each have separate implementations under scala-3/ and scala-2/ source directories.
Under scala-3/, the existing method bodies remain exactly as they were before Scala 2 support was added. inline helpers stay in place. No hot path is touched.
Under scala-2/, equivalent semantics are provided using final val and final def where Scala 3 uses inline val and inline def. The Scala 2 surface is behaviorally equivalent; the Scala 2 compiler cannot honour inline in the same way, but the final modifier prevents virtual dispatch and allows the JIT similar opportunities.
Maintenance notes​
Any change to the behavior or public API of Stream, Sink, Reader, Writer, Pipeline, or Interpreter must be applied to both the scala-2/ and scala-3/ source trees. The split is intentional and permanent; it is not scaffolding to be collapsed later.
When making changes that touch hot combinators, re-run streams-benchmark and verify that zb_flatMap, zb_concat, and zb_filterMap do not regress relative to the main baseline.