Field Notes

Scala/Play, Part 15: From String Errors to Typed Scala Results

Scala/Play, Part 15: From String Errors to Typed Scala Results

My Scala result flow already returned Either: a successful lookup produced a policy, and a rejected request produced a sentence. That sentence was doing two jobs: identifying the problem and deciding how to explain it.

This step separates those jobs. I keep the original string-error example beside a typed version, so the difference stays visible. The project uses Scala 2.13.18; there is no Play controller or asynchronous operation hiding in this example.

Zakaria's navy-hoodie mascot arranging a structured object beside loose paper strips
Keep the structure of an error before choosing its wording.

The left-hand type is a design decision

Our recent generic methods and Box[A] examples made square brackets less mysterious: they specify types. The same idea helps read these result types:

Either[String, PolicySnapshot]
Either[PolicyInputError, PolicySnapshot]

The right side still contains a successful policy. Only the left side changes. Instead of any string, it contains an instance from a named family of errors. This does not introduce a new result container. It changes the information that an existing Either carries.

A small family of error values

sealed trait PolicyInputError

final case class PolicyNotFound(number: String)
    extends PolicyInputError

final case class MalformedInstallments(text: String)
    extends PolicyInputError

final case class NonPositiveInstallments(count: Int)
    extends PolicyInputError

extends says that each case belongs to the shared type. sealed requires direct subclasses to be declared in the same source file. The concrete cases are also final, preventing another layer of subclasses. The official Scala pattern-matching guide explains how a sealed family supports exhaustiveness checking.

From my Java background, the useful comparison is a sealed interface with record-like alternatives: each has a name and relevant fields. None extends Throwable. Constructing PolicyNotFound("POL-999") creates data; it does not throw.

Names are not validation, either. The constructor would accept NonPositiveInstallments(12). Our validation method must choose that case only when the count is non-positive.

Three PolicyInputError alternatives with their field types and concrete example values
The shared type preserves which problem occurred and the data attached to it.

Convert absence explicitly

def requirePolicy(
  number: String
): Either[PolicyInputError, PolicySnapshot] = {
  OptionLesson.findPolicy(number) match {
    case Some(policy) => Right(policy)
    case None         => Left(PolicyNotFound(number))
  }
}

The lookup still returns Option. Our method interprets None as a particular domain error. There is no automatic conversion from “nothing found” to “policy not found”; we supply that meaning.

Read the unsuccessful branch inside out: construct PolicyNotFound(number), then put it inside Left. The method returns normally with a value the caller can inspect.

Unreadable and unacceptable are different

import scala.util.{Failure, Success, Try}

def readInstallments(
  text: String
): Either[PolicyInputError, Int] = {
  Try(text.toInt) match {
    case Success(count) =>
      if (count > 0) Right(count)
      else Left(NonPositiveInstallments(count))

    case Failure(_) =>
      Left(MalformedInstallments(text))
  }
}

The braces in the import select three names from scala.util. Try surrounds only the integer conversion. In Failure(_), the underscore ignores the captured exception rather than binding it to a variable.

That is a deliberate simplification for this parser: the error retains the original text, not the exception. It is not a recommendation to discard unexpected infrastructure failures throughout an application. Empty text and values outside the Int range also enter this malformed-input case.

readInstallments("12")    // Right(12)
readInstallments("0")     // Left(NonPositiveInstallments(0))
readInstallments("hello") // Left(MalformedInstallments(hello))

Zero is readable, but rejected by our rule. “hello” cannot be read as an integer. Distinct cases preserve that distinction without inspecting message text.

The composition stays familiar

def installmentLabel(
  number: String,
  text: String
): Either[PolicyInputError, String] = {
  for {
    policy <- requirePolicy(number)
    count  <- readInstallments(text)
  } yield policy.number + ": " + policy.basePremium +
    " EUR / " + count + " = " +
    (policy.basePremium / count) + " EUR"
}

Both methods share the error type. The same flatMap/map composition passes successful values forward and keeps the first Left. The second operation runs only if the lookup succeeds. This is sequential result composition, not parallel execution or error accumulation.

For POL-001 and "12", the verified result is Right(POL-001: 600 EUR / 12 = 50 EUR). For a missing policy and malformed count together, it is Left(PolicyNotFound(POL-999)); parsing is skipped. Integer division here illustrates a whole-euro base-premium label, not a production payment schedule.

Successful lookup continues to parsing and label creation; a missing policy returns a typed Left and skips both later steps
Changing the error payload does not change first-error behavior.

Choose wording at the display boundary

def describe(error: PolicyInputError): String = error match {
  case PolicyNotFound(number) =>
    "Policy not found: " + number
  case MalformedInstallments(text) =>
    "Cannot read installments: " + text
  case NonPositiveInstallments(count) =>
    "Installments must be positive: " + count
}

Inside a pattern, PolicyNotFound(number) extracts a field from an existing object; it does not construct another one. A separate display method keeps a Right label or delegates a Left error to this renderer.

We temporarily omitted the final case during the lesson. Scala emitted a non-exhaustive-match warning naming NonPositiveInstallments; compilation still succeeded with our settings. We restored the case before running. Sealing helps the compiler warn, but does not magically make every match safe.

For a Spring service, the architectural comparison is returning structured domain information and letting an outer layer choose the response wording. No HTTP status mapping has been implemented here. Error data and presentation are simply separate choices now.

What actually ran

sbt "runMain learning.PremiumLesson" test

The typed flow passed sixteen outcome assertions plus a deliberately unreachable failing assertion that checks skipped work. The sealed-error lesson adds six assertions. All nine existing ScalaTest tests also passed; they cover the earlier flow and exception boundary, not a new typed-flow suite. The new lesson assertions run through runMain, not automatically through sbt test.

The runnable snapshot preserves both implementations. These are guided, verified examples, not a claim that generated code proves independent mastery. The useful takeaway is smaller: keep error identity and data in the result, and choose a sentence when a sentence is needed.