Error handling in functional programs: "railway oriented" programming

ITT8060 Advanced Programming · Autumn 2026

Tallinn University of Technology

Based on Scott Wlaschin's Railway Oriented Programming (fsharpforfunandprofit.com/rop)

Full prose version with runnable examples: notes.html

ITT8060

1 · The happy path is a composition

A use case is a pipeline of functions glued with >>, and error handling must not break that shape.

ITT8060

The model

"As a user I want to update my name and email address." The user records are a map keyed by a user identifier (lecture 5); a request carries the new name and email.

type UserId = UserId of int
type User = { id: UserId; name: string; email: string }
type Request = { userId: UserId; name: string; email: string }
type Users = Map<UserId, User>
let request =
  { userId = UserId 1; name = "Kati Karu"
    email = " Kati.Karu@TalTech.ee " }

users holds Kati (UserId 1) and Mart (UserId 2). The model is pure: sending a mail produces a Message value, or fails when the mail server has no route to the address.

ITT8060

Imperative code, functional flow

string ExecuteUseCase()
{
  var request = receiveRequest();
  validateRequest(request);
  canonicalizeEmail(request);
  db.updateDbFromRequest(request);
  smtpServer.sendEmail(request.Email);
  return "Success";
}
let happyPath (users: Users) =
  validateOrFail
  >> canonicalizeEmail
  >> updateOrFail users
  >> sendEmail
  >> confirm

Wlaschin's imperative version (C#), and the F# composition: validate, canonicalize, update, send, confirm — every step a one-track function.

What is happyPath users request?

"Success: verification mail sent to kati.karu@taltech.ee"

ITT8060

2 · A function can have two outputs

A function that can fail has two outputs, and Result makes both visible in its type.

ITT8060

Two tracks

type Result<'T, 'TError> = Ok of 'T | Error of 'TError is in FSharp.Core (since 2017). Its constructors are Ok and Error.

Wlaschin's slides call them Success and Failure.

A function of type 'a -> Result<'b, 'e> has one track in and two tracks out: a "switch".

ITT8060
let nameNotBlank (r: Request) =
  if r.name = "" then
    Error "Name must not be blank"
  else
    Ok r

A switch

What type does F# infer for nameNotBlank?

val nameNotBlank:
  r: Request -> Result<Request,string>

What does nameNotBlank return for a request with a blank name, and for request?

Error "Name must not be blank", and Ok with the request unchanged.

ITT8060

Option or Result?

None says that something is missing; Error says why.

A map lookup, Map.tryFind?

An option: absence is normal and needs no reason (lecture 5's enrolled).

Parsing a number typed by a user?

A Result: the reason matters to the user, and there is more than one.

Removing a value that is not in a search tree (lecture 6's remove returned the tree unchanged)?

A design question: silent when the caller does not care, an option when "nothing removed" is enough, a Result when the caller must know what was missing (exercise 1).

ITT8060

3 · Bind is the adapter

Two switches do not compose, but bind turns a switch into a two-track function, and those compose.

ITT8060
let bind f x =
  match x with
  | Ok a -> f a
  | Error e -> Error e

bind

bind takes a switch and returns a two-track function: on the Ok track it applies the switch, on the Error track it passes the error on.

val bind:
  f: ('a -> Result<'b,'c>)
  -> x: Result<'a,'c> -> Result<'b,'c>

The library has it as Result.bind.

ITT8060
let validateRequest =
  nameNotBlank
  >> Result.bind name50
  >> Result.bind emailNotBlank
  >> Result.bind emailHasAt

The validation chain

emailNotBlank and emailHasAt are switches like nameNotBlank. The first switch needs no bind: its input is one track.

What does validateRequest return for a request with a 60-character name, and for request?

Error "Name must not be longer than 50 chars": the first check passed, the error of the second bypassed the rest. And Ok with the request.

ITT8060

Bind is about shape

bind only cares that its argument is a switch; the type on the track may change from step to step.

findUser users : Request -> Result<User, string> looks the user up.

validateRequest >> Result.bind (findUser users)
  : Request -> Result<User, string>

The track entered with a Request and leaves with a User.

ITT8060

4 · Other shapes fit with adapters

Functions of other shapes fit the two-track model through small adapters: map, tee, tryCatch and mapError.

ITT8060

Four shapes

Not every step is a switch.

A one-track function 'a -> 'b that cannot fail (canonicalizeEmail), and a "dead-end" function 'a -> unit that returns nothing (logging):

A function that throws an exception (sendEmail when the mail server has no route), and a supervisory function that handles both tracks (translating an error):

Each gets an adapter.

ITT8060
let map f x =
  match x with
  | Ok a -> Ok (f a)
  | Error e -> Error e

One-track functions: map

map applies a one-track function on the Ok track and passes an error on. The library has it as Result.map.

How would you define map with bind?

let mapViaBind f = bind (f >> Ok): a one-track function followed by Ok is a switch that never fails.

ITT8060
let tee f x =
  f x
  x

Dead ends: tee

A function that returns unit ends the track. tee calls it and passes the input on, like a T-junction in a pipe.

> request
  |> tee (fun r -> printfn "got %s" r.name)
  |> nameNotBlank;;
got Kati Karu
val it: Result<Request,string> = Ok { ... }
ITT8060

Exceptions

failwith "message" raises an exception with a message (validateOrFail did). exception Name of … declares your own, raised with raise (lecture 5's UnknownArticle). try … with catches them.

> validateOrFail { request with name = "" };;
System.Exception: Name must not be blank
Stopped due to error

> int "forty-two";;
System.FormatException: The input string 'forty-two' was not in a
correct format.
Stopped due to error

Wlaschin's guideline: convert exceptions into errors where they arise; do not use exceptions for control flow.

ITT8060
let tryCatch f x =
  try Ok (f x)
  with ex -> Error ex.Message

tryCatch

tryCatch turns a function that may throw into a switch.

What are tryCatch int "42" and tryCatch int "forty-two"?

Ok 42
Error "The input string 'forty-two'
       was not in a correct format."

And tryCatch sendEmail for a user at example.com?

Error "no route to kati@example.com"

The mail step is a switch now.

ITT8060

Which adapter?

canonicalizeEmail : Request -> Request?

Result.map canonicalizeEmail.

A logging function Request -> unit?

tee log, then compose.

sendEmail : User -> Message, which may throw?

tryCatch sendEmail: now a switch, so Result.bind (tryCatch sendEmail).

updateUser : Request -> Result<User, string>?

Result.bind updateUser.

A function on the error text, string -> string?

Result.mapError f: applied on the Error track, an Ok passes.

ITT8060

Before and after

let happyPath (users: Users) =
  validateOrFail
  >> canonicalizeEmail
  >> updateOrFail users
  >> sendEmail
  >> confirm
let executeUseCase (users: Users) =
  validateRequest
  >> Result.map canonicalizeEmail
  >> Result.bind (updateUser users)
  >> Result.bind (tryCatch sendEmail)
  >> returnMessage

Before: no error handling. After: every error handled, and the composition kept its shape (Wlaschin's review: "still clean and elegant").

The imperative version grew from six lines to eighteen; the functional one from five steps to five steps, each fitted to two tracks.

ITT8060

5 · Errors are part of the domain

Errors are domain data: a union of error cases documents everything that can go wrong, and text is produced once, at the end.

ITT8060
type UseCaseError =
  | NameMustNotBeBlank
  | NameTooLong of int
  | EmailMustNotBeBlank
  | EmailNotValid of string
  | UserNotFound of UserId
  | MailNotSent of string

Strings are poor errors

A string cannot be matched on, carries no data and is easy to mistype.

A union of error cases is type-safe documentation of everything that can go wrong, and it cannot go out of date.

What does validateRequestT, the chain over UseCaseError, return for the email kati.karu?

Error (EmailNotValid "kati.karu")

The error carries data.

ITT8060

Text once, at the end

let errorText e =
  match e with
  | NameMustNotBeBlank -> "Name must not be blank"
  | NameTooLong n -> $"Name must not be longer than 50 chars ({n} given)"
  | EmailMustNotBeBlank -> "Email must not be blank"
  | EmailNotValid s -> $"Email {s} is not valid"
  | UserNotFound (UserId i) -> $"User {i} not found"
  | MailNotSent s -> $"Mail not sent: {s}"

Every message in one place, at the end of the pipeline: a translation changes one function, and a log can use another.

What does Result.mapError errorText make of Error (EmailNotValid "kati.karu")?

Error "Email kati.karu is not valid". mapError is the translation at a boundary.

ITT8060
type EmailAddress = EmailAddress of string

let createEmail (s: string) =
  if s.Contains '@' then Ok (EmailAddress s)
  else Error (EmailNotValid s)

Constrained types

An email address is not just a string: a one-case union (lecture 5's StudentId) keeps it distinct, and the function that builds it is a switch.

What are createEmail "kati@taltech.ee" and createEmail "kati"?

Ok (EmailAddress "kati@taltech.ee")
Error (EmailNotValid "kati")

With a private constructor, every EmailAddress is valid.

ITT8060

Illegal states unrepresentable

Lecture 5: a type is a promise. A value on the Ok track has been checked, and the type says so.

Wlaschin's example: "a contact must have an email or a postal address".

A record with two optional fields allows a contact with neither.

A union with the cases email only, address only and both makes the rule a type: the invalid contact cannot be written down.

ITT8060

When not to use Result

Wlaschin, Against railway oriented programming:

  • you need a stack trace (diagnostics)
  • the workflow throws at the end anyway (fail fast)
  • nobody cares about the error (apathy)
  • every I/O error would have to be modelled
  • performance, or interoperability with other .NET code

Use Result for domain errors; leave panics and infrastructure failures to exceptions.

ITT8060

6 · No result, one result, many results: one shape

option, Result and list answer "how many results?", and they share one composition shape.

ITT8060

One shape

A function may return no result, one result or an error, or many results. Each type has its bind:

Option.bind  : ('a -> 'b option)      -> 'a option      -> 'b option
Result.bind  : ('a -> Result<'b, 'e>) -> Result<'a, 'e> -> Result<'b, 'e>
List.collect : ('a -> 'b list)        -> 'a list        -> 'b list

What is [1; 2; 3] |> List.collect (fun n -> [n; n * 10])?

[1; 10; 2; 20; 3; 30]: for every element, the results of the function, all in one list. List.collect (lecture 4) is the bind of lists.

ITT8060

Summary

Wlaschin's recipe for handling errors in a functional way:

  1. Use Result<'T, 'TError>: a function that can fail returns Ok or Error.
  2. Turn switches into two-track functions with bind; fit the other shapes with map, tee and tryCatch.
  3. Compose the two-track functions with >>, as on the happy path.
  4. Make the error cases a union: first-class, documented, converted to text once at the end.

The shape of bind is the shape of Option.bind and List.collect.

ITT8060

Homework and reading

Homework 5 is out this week.

Reading: Scott Wlaschin, Railway Oriented Programming (fsharpforfunandprofit.com/rop) and Against Railway-Oriented Programming; Domain Modeling Made Functional, chapter 10, "Implementation: Working with Errors".

The complete programs and the exercises: the Worked examples and Exercises sections of notes.html.

ITT8060