Partial functions in Scala: useful precision, not incomplete code
How PartialFunction models a function defined for only part of its input, how collect and recover use it, and where ordinary total functions are safer.
A partial function has a defined domain
An ordinary Function1[A, B] promises that it can produce a B for every A it receives. A PartialFunction[A, B] narrows that promise: it is defined for some values of A and exposes isDefinedAt so callers can ask whether a value belongs to that domain.
A case block can be assigned directly to a PartialFunction. The compiler generates both the matching behaviour and the domain check. Calling apply with an unsupported value can still throw MatchError, so the type does not make arbitrary direct invocation safe.
The important distinction is semantic rather than syntactic. A partial function is appropriate when selective handling is the operation itself. It is not a substitute for returning Option or Either from a business operation that callers expect to invoke normally.
val statusText: PartialFunction[Int, String] = {
case 200 => "ok"
case 404 => "not found"
}
statusText.isDefinedAt(200) // true
statusText.isDefinedAt(500) // false
statusText.lift(404) // Some("not found")
statusText.lift(500) // Nonecollect is the natural collection operation
Collection methods reveal why PartialFunction is useful. map requires an output for every element. filter decides which elements remain but does not transform them. collect combines those operations: it selects the values for which a partial function is defined and transforms only those values.
This is clearer than returning Option from map and flattening when selection and transformation are one decision. It also keeps the accepted input shapes next to the transformation that handles them.
Use collect when discarded inputs are expected. If an unsupported value means corrupt data or a failed invariant, silently omitting it can hide a problem; traverse the inputs with Either or validate them explicitly instead.
sealed trait Event
final case class UserCreated(id: String) extends Event
final case class EmailChanged(id: String, email: String) extends Event
final case class Heartbeat(at: Long) extends Event
val affectedUsers: List[String] = events.collect {
case UserCreated(id) => id
case EmailChanged(id, _) => id
}
// Heartbeats are deliberately outside this operation's domain.Composition preserves the defined cases
orElse combines two partial functions. The left-hand function gets the first opportunity to handle an input; the right-hand function is consulted only when the first is not defined. This makes ordering part of the behaviour and deserves a test when cases overlap.
andThen transforms the successful output while keeping the original input domain. lift converts a PartialFunction[A, B] into a total Function1[A, Option[B]], which is often safer at a boundary where a value may not be handled.
At a lower level, applyOrElse can test and apply without evaluating the pattern match twice. Collection implementations use this shape for efficiency. Application code should normally prefer collect, lift or orElse because they communicate the intent directly.
val clientError: PartialFunction[Int, String] = {
case code if code >= 400 && code < 500 => "client"
}
val serverError: PartialFunction[Int, String] = {
case code if code >= 500 && code < 600 => "server"
}
val classifyError = clientError.orElse(serverError)
val safeClassify: Int => Option[String] = classifyError.lift
safeClassify(503) // Some("server")
safeClassify(204) // NoneRecovery handlers are partial by design
Future.recover and recoverWith accept partial functions because a recovery policy commonly handles only selected failures. An unmatched exception remains a failed Future rather than being accidentally converted into a success.
Keep these handlers narrow. A broad case matching Throwable can turn programming errors, interruption or infrastructure failures into misleading domain responses. Match only failures for which the current layer has a legitimate fallback.
The same rule applies to actor receive handlers and request-routing DSLs: partial functions are effective dispatch mechanisms, but the boundary still needs an explicit policy for unhandled input.
def loadAccount(id: AccountId): Future[Account] =
repository.find(id).recoverWith {
case _: RepositoryTimeout =>
cache.find(id).flatMap {
case Some(account) => Future.successful(account)
case None => Future.failed(AccountUnavailable(id))
}
}
// Other failures remain failures.Prefer a total result for domain decisions
Eligibility checks, validation and state transitions usually need to explain rejection rather than declare themselves undefined. Either[Reason, Result] or Option[Result] gives every input a result and forces the caller to acknowledge the negative case.
A useful test is to ask what an unsupported input means. If it means “this handler is not responsible for that message”, PartialFunction is a good fit. If it means “the request was invalid” or “the transition was rejected”, return that fact as data.
Partial functions are therefore precise tools for selection, dispatch and selective recovery. Used at those boundaries they remove branching noise. Used as ordinary service methods they can move a missing case from compile-time reasoning into a runtime MatchError.