jonnifyfsharp

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.

C2.03.2 · part 1

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 'T on success, Error of 'E on failure.
'E
the failure type — a string message 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
None says a value is missing; Error e says a step failed and names why.
C2.03.2 · part 2

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"
C2.03.2 · part 3

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>
This is a genuine interop nuance, not an F# quirk: the ambiguity comes from the runtime growing the overload set under F#'s feet. The cure is the same one F# always offers for an underconstrained type — write the annotation. Every 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 Result.map (· * 2) Result "42" TryParse ? Ok · / · Error map · * 2 Ok 84 Error "…"
s = "42" · parse "42" |> Result.map (· * 2) = Ok 84
Without JavaScript: the same three results, computed by hand
input sparse s… |> Result.map (· * 2)
"42"Ok 42Ok 84
"-3"Ok -3Ok -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.

C2.03.2 · part 4

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

Related in this course