Skip to main content
Version: 2.x

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 inline definitions 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.concat
  • Stream.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:

BenchmarkBaseline (main)Trait-extraction draftChange
StreamPipelineBench.zb_flatMap14924.328 ops/s1178.997 ops/s~12x regression
StreamPipelineBench.zb_concat26003.818 ops/s22044.497 ops/sregression
StreamPipelineBench.zb_filterMap54812.709 ops/s50349.471 ops/sregression

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.