Dive C2.03.2
From exceptions to Result
Expected failure is a value with a reason — not a throw the caller might forget to catch.
A C# method that parses a string can throw FormatException when the
string is not a number. The throw does not show in the return type, so the caller is free to ignore it, and an
unhandled exception travels up the stack until something stops it — or nothing does. F# draws a line: an
exception is for the exceptional, the thing the program cannot foresee; an expected failure —
a bad parse, a missing record, a rejected input — is a value. That value has type
Result<'T,'E>: Ok v when the operation succeeded,
Error e when it failed, with the reason in e. The caller reads
both cases, or the program does not compile. This dive also names a real .NET 9 interop trap that bites on the
first parse.
Ok of 'T, or Error of 'E
Result is a union with two type parameters: the type of a success and the type of a
failure. Where Option records that a value is absent,
Result records why a step failed — the failing side carries a reason. A string-to-int
parser is the canonical example: .NET's Int32.TryParse returns a tuple of a success
flag and the parsed value, and F# turns the two outcomes into Ok or
Error with a message.
let parse (s: string) = match System.Int32.TryParse s with | true, v -> Ok v | false, _ -> Error ("not an int: " + s) // val parse : string -> Result<int,string> parse "42" // val it : Result<int,string> = Ok 42 parse "x" // val it : Result<int,string> = Error "not an int: x"
- Result<'T,'E>
- a union:
Ok of 'Ton success,Error of 'Eon failure. - 'E
- the failure type — a
stringmessage here, but free to be a union of named error cases. - vs. an exception
- the failure is a returned value the caller must read, not a thrown one it might forget to catch.
- vs. Option
Nonesays a value is missing;Error esays a step failed and names why.
The C# the reader knows, beside the F#
In C#, the throwing form needs a try/catch at every call that wants to recover,
and the type does not remind anyone to write it. The TryParse form returns a flag
and an out variable instead — closer to F#, but the failure carries no reason. The
F# version returns the success and the reason in one value, and the caller reads which arm it landed on.
C# — throw, or a flag with no reason
// the throwing form: the failure is invisible in the type int n = int.Parse(s); // throws FormatException on "x" // the TryParse form: a flag, but no reason for the failure bool ok = int.TryParse(s, out var v); // ok=false on "x"; why is lost
F# — Ok or Error, the reason carried in the value
let describe s = match parse s with | Ok v -> "parsed " + string v | Error e -> "rejected: " + e // val describe : string -> string describe "42" // val it : string = "parsed 42" describe "x" // val it : string = "rejected: not an int: x"
The .NET 9 TryParse trap
The annotation (s: string) on parse is not decoration. On
.NET 9, written without it, parse does not compile. .NET added
ReadOnlySpan<char> and ReadOnlySpan<byte>
overloads to Int32.TryParse, so when s is left unannotated
the compiler has more than one candidate and cannot pick one from the use alone. It stops and asks for a type.
// rejected on .NET 9: the parameter type is ambiguous across the TryParse overloads let parse s = match System.Int32.TryParse s with | true, v -> Ok v | false, _ -> Error "bad" // error FS0041: A unique overload for method 'TryParse' could not be determined // based on type information prior to this program point. // fixed: annotate the parameter, and the overload is pinned to string let parse (s: string) = match System.Int32.TryParse s with | true, v -> Ok v | false, _ -> Error ("not an int: " + s) // val parse : string -> Result<int,string>
parse on this page carries
(s: string) for exactly this reason.Explore parse s |> Result.map (· * 2)
Pick an input string. parse sends it down one of two rails: the
Ok rail when the string is an integer, carrying the parsed
value; the Error rail otherwise, carrying the message. Then
Result.map (fun x -> x * 2) runs on the result. On the
Ok rail it doubles the value; on the Error rail it does
nothing — map leaves an Error untouched, so the failure
carries straight through and short-circuits the rest.
parse s, then Result.map (· * 2) · an Ok rail and an Error rail
| input s | parse s | … |> Result.map (· * 2) |
|---|---|---|
| "42" | Ok 42 | Ok 84 |
| "-3" | Ok -3 | Ok -6 |
| "x" | Error "not an int: x" | Error "not an int: x" |
On the Error rail, map is a pass-through: the
failure built by parse arrives at the end unchanged. One Error
anywhere short-circuits every step that follows.
When the error grows, model it
A string message is fine while there is one failure. When a function can fail in
more than one way, the failing side earns its own type: a union with a case per reason. The caller then
matches on the error the way it matches on the success, and the compiler lists every
failure it must handle.
type ParseError = | Empty | NotAnInt of string | OutOfRange of int let parseAge (s: string) = match System.Int32.TryParse s with | false, _ when s = "" -> Error Empty | false, _ -> Error (NotAnInt s) | true, v when v < 0 || v > 130 -> Error (OutOfRange v) | true, v -> Ok v // val parseAge : string -> Result<int,ParseError> parseAge "42" // val it : Result<int,ParseError> = Ok 42 parseAge "x" // val it : Result<int,ParseError> = Error (NotAnInt "x") parseAge "" // val it : Result<int,ParseError> = Error Empty parseAge "999" // val it : Result<int,ParseError> = Error (OutOfRange 999)
C# habit
Throw on a bad parse, then try/catch by exception type at each caller — the type does not list what can go wrong.
F# form
Result<int, ParseError> — the failures are a union the compiler enumerates, threaded with map · bind.
Where this leads
Exceptions did not vanish — they stay for the genuinely exceptional, and for the C# libraries that still
throw. The next dive is the seam: how to catch that throw at the boundary, turn it into a
Result, and keep every step deeper in the F# core reading Ok
and Error.
References
Sources
- Microsoft — F# documentation — results, error handling, and the guidance on exceptions versus results.
- FSharp.Core API reference — the
Resultmodule:map,mapError,bind. - Microsoft — .NET documentation —
Int32.TryParseand itsReadOnlySpanoverloads.
Related in this course
- C2.03.3 — The interop boundary — wrap a throwing API in a
Result. - C1.08 — Option, Result & railway-oriented errors —
Resultandbindin language depth. - Elixir — Purity — the BEAM tuple
{:error, reason}, returned rather than raised.