A library that makes some Scala 2.13 APIs available on Scala 2.11 and 2.12, facilitating cross-building Scala 2.13 and 3.0 code on older versions
—
Utility methods for functional programming patterns, debugging, and creating fluent APIs through method chaining operations.
final class ChainingOps[A](private val self: A) extends AnyVal {
def tap[U](f: A => U): A
def pipe[B](f: A => B): B
}Extension methods that enable fluent method chaining for any type.
def tap[U](f: A => U): AApplies a function to the value for its side effects and returns the original value unchanged. This is useful for debugging, logging, or performing side effects in the middle of a method chain.
Usage:
import scala.util.chaining._
val result = List(1, 2, 3, 4, 5)
.tap(xs => println(s"Original list: $xs"))
.filter(_ % 2 == 0)
.tap(evens => println(s"Even numbers: $evens"))
.map(_ * 2)
.tap(doubled => println(s"Doubled: $doubled"))
// Output:
// Original list: List(1, 2, 3, 4, 5)
// Even numbers: List(2, 4)
// Doubled: List(4, 8)
// result: List(4, 8)Common use cases:
def pipe[B](f: A => B): BTransforms the value by applying a function to it. This enables forward function application and can make code more readable by avoiding nested function calls.
Usage:
import scala.util.chaining._
// Instead of nested function calls
val result1 = math.abs(math.pow(2.5, 3) - 10)
// Use pipe for forward flow
val result2 = 2.5
.pipe(math.pow(_, 3))
.pipe(_ - 10)
.pipe(math.abs)
// Both give the same result: 5.375Advanced usage:
case class Person(name: String, age: Int)
case class Adult(person: Person)
case class Senior(adult: Adult)
def toAdult(p: Person): Option[Adult] =
if (p.age >= 18) Some(Adult(p)) else None
def toSenior(a: Adult): Option[Senior] =
if (a.person.age >= 65) Some(Senior(a)) else None
val person = Person("Alice", 70)
val result = person
.pipe(toAdult)
.flatMap(_.pipe(toSenior))
.tap(_.foreach(s => println(s"Senior: ${s.adult.person.name}")))
// Output: Senior: Alice
// result: Some(Senior(Adult(Person(Alice,70))))trait ChainingSyntax {
@`inline` implicit final def scalaUtilChainingOps[A](a: A): ChainingOps[A] =
new ChainingOps(a)
}Provides the implicit conversion that adds chaining methods to all types.
object chaining extends ChainingSyntaxThe main entry point for chaining operations. Import this to get access to tap and pipe methods on all types.
import scala.util.chaining._
def processUserData(rawData: String): Option[Int] = {
rawData
.tap(data => println(s"Processing: $data"))
.trim
.tap(trimmed => println(s"After trim: '$trimmed'"))
.pipe(_.toIntOption)
.tap {
case Some(num) => println(s"Parsed number: $num")
case None => println("Failed to parse number")
}
.filter(_ > 0)
.tap(filtered => println(s"Final result: $filtered"))
}
processUserData(" 42 ")
// Output:
// Processing: 42
// After trim: '42'
// Parsed number: 42
// Final result: Some(42)import scala.util.chaining._
case class DatabaseConfig(
host: String,
port: Int,
database: String,
maxConnections: Int
)
def createDatabaseConfig(properties: Map[String, String]): Either[String, DatabaseConfig] = {
properties
.tap(props => println(s"Loading config from ${props.size} properties"))
.pipe { props =>
for {
host <- props.get("db.host").toRight("Missing db.host")
port <- props.get("db.port").flatMap(_.toIntOption).toRight("Invalid db.port")
database <- props.get("db.name").toRight("Missing db.name")
maxConn <- props.get("db.maxConnections").flatMap(_.toIntOption).toRight("Invalid db.maxConnections")
} yield DatabaseConfig(host, port, database, maxConn)
}
.tap {
case Right(config) => println(s"Successfully created config: $config")
case Left(error) => println(s"Configuration error: $error")
}
}import scala.util.chaining._
case class SalesRecord(product: String, amount: Double, region: String)
def processSalesData(records: List[SalesRecord]): Map[String, Double] = {
records
.tap(data => println(s"Processing ${data.length} sales records"))
.filter(_.amount > 0)
.tap(filtered => println(s"${filtered.length} records after filtering"))
.groupBy(_.region)
.tap(grouped => println(s"Grouped into ${grouped.size} regions"))
.view
.mapValues(_.map(_.amount).sum)
.toMap
.tap(totals => println(s"Region totals: $totals"))
}
val sales = List(
SalesRecord("laptop", 999.99, "north"),
SalesRecord("mouse", 29.99, "south"),
SalesRecord("laptop", -50.0, "north"), // negative - will be filtered
SalesRecord("keyboard", 79.99, "south")
)
val result = processSalesData(sales)
// Output:
// Processing 4 sales records
// 3 records after filtering
// Grouped into 2 regions
// Region totals: Map(north -> 999.99, south -> 109.98)import scala.util.chaining._
import scala.util.{Try, Success, Failure}
case class ApiResponse(status: Int, body: String)
case class User(id: Int, name: String, email: String)
def parseUser(response: ApiResponse): Option[User] = {
response
.tap(resp => println(s"Received response: ${resp.status}"))
.pipe { resp =>
if (resp.status == 200) Some(resp.body) else None
}
.tap {
case Some(body) => println(s"Parsing body: $body")
case None => println("Non-200 response, skipping parse")
}
.flatMap { body =>
Try {
// Simplified JSON parsing
val parts = body.split(",")
User(
id = parts(0).toInt,
name = parts(1),
email = parts(2)
)
}.toOption
}
.tap {
case Some(user) => println(s"Successfully parsed user: $user")
case None => println("Failed to parse user")
}
}import scala.util.chaining._
sealed trait ProcessingError
case class ValidationError(message: String) extends ProcessingError
case class NetworkError(message: String) extends ProcessingError
case class ParseError(message: String) extends ProcessingError
def processRequest(input: String): Either[ProcessingError, String] = {
input
.pipe(validateInput)
.tap {
case Right(valid) => println(s"Input validated: $valid")
case Left(error) => println(s"Validation failed: $error")
}
.flatMap(fetchData)
.tap {
case Right(data) => println(s"Data fetched: ${data.length} chars")
case Left(error) => println(s"Fetch failed: $error")
}
.flatMap(parseData)
.tap {
case Right(result) => println(s"Parse successful: $result")
case Left(error) => println(s"Parse failed: $error")
}
}
def validateInput(input: String): Either[ValidationError, String] = {
if (input.nonEmpty) Right(input.trim)
else Left(ValidationError("Input cannot be empty"))
}
def fetchData(input: String): Either[NetworkError, String] = {
// Simulate network call
if (input.startsWith("valid")) Right(s"data_for_$input")
else Left(NetworkError("Network request failed"))
}
def parseData(data: String): Either[ParseError, String] = {
if (data.contains("data_for_")) Right(data.replace("data_for_", "result_"))
else Left(ParseError("Invalid data format"))
}import scala.util.chaining._
case class HttpRequest(
url: String = "",
method: String = "GET",
headers: Map[String, String] = Map.empty,
body: Option[String] = None,
timeout: Int = 30000
)
def buildRequest(baseUrl: String): HttpRequest = {
HttpRequest()
.pipe(_.copy(url = s"$baseUrl/api/users"))
.pipe(_.copy(method = "POST"))
.pipe(req => req.copy(headers = req.headers + ("Content-Type" -> "application/json")))
.pipe(req => req.copy(headers = req.headers + ("Authorization" -> "Bearer token123")))
.pipe(_.copy(body = Some("""{"name": "John", "email": "john@example.com"}""")))
.pipe(_.copy(timeout = 60000))
.tap(request => println(s"Built request: $request"))
}tap and pipe methods are implemented as inline methods on a value class, so there's minimal runtime overhead@inline annotation ensures the method calls are inlined at compile timeChainingOps extends AnyVal to avoid object allocation in most casesval result = process(transform(validate(input)))val result = input
.pipe(validate)
.pipe(transform)
.pipe(process)val validated = validate(input)
println(s"Validated: $validated")
val transformed = transform(validated)
println(s"Transformed: $transformed")
val result = process(transformed)val result = input
.pipe(validate)
.tap(validated => println(s"Validated: $validated"))
.pipe(transform)
.tap(transformed => println(s"Transformed: $transformed"))
.pipe(process)Install with Tessl CLI
npx tessl i tessl/maven-org-scala-lang-modules--scala-collection-compat