jonnifyfsharp

Dive C1.08.2

Result

The same two-case shape as Option — but the failing side carries a reason.

Where Option says whether a value is present, Result<'T,'TError> says whether an operation succeeded and, when it did not, why. It has two cases: Ok x holds a successful value of type 'T; Error e holds a failure value of type 'TError — commonly a string message, but it can be any type. The transforming functions mirror Option's: Result.map changes the Ok, Result.mapError changes the Error, and Result.bind chains a step that can itself fail. This dive covers all three, and one real wrinkle of the F# type system that surfaces the first time you map a bare Ok at the REPL.

C1.08.2 · part 1

Ok of 'T, or Error of 'TError

Result is a union with two type parameters: the type of a success and the type of a failure. Its declaration in FSharp.Core is the two constructors below. A function that can fail with a described error returns one: the success path carries the value, the failure path carries the reason.

type Result<'T, 'TError> =
    | Ok of 'T
    | Error of 'TError

A string-to-int parser is the canonical example. .NET's Int32.TryParse returns a tuple of a success flag and the parsed value; F# matches on it and turns the two cases into Ok or Error with a message. The signature string -> Result<int,string> tells the caller both how it succeeds and how it fails.

let parse (s: string) =
    match System.Int32.TryParse s with
    | true, v  -> Ok v
    | false, _ -> Error (sprintf "not an int: %s" 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,'TError>
a union: Ok of 'T on success, Error of 'TError on failure.
'TError
the failure type — often a string message, but free to be a union of error cases.
vs. Option
Option records that a value is missing; Result records why a step failed.
vs. exceptions
the failure is a returned value the caller must handle, not a thrown one it might forget to catch.

Explore parse s

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 exact message the function built. The readout shows the precise FSI value, message and all.

parse s · an Ok rail and an Error rail

input s parse Result<int,string> "42" TryParse ? Ok · / · Error Ok 42 Error "…"
s = "42" · parse "42" = Ok 42
Without JavaScript: the same three results, computed by hand
input sparse s
"42"Ok 42
"-3"Ok -3
"x"Error "not an int: x"

The two rails are not exceptional flow and normal flow — they are two values of one type. Whichever rail a call lands on, the caller reads it the same way: with a match on Ok and Error.

C1.08.2 · part 2

map, mapError, bind

Three functions cover the work. Result.map transforms the value inside an Ok and leaves an Error untouched. Result.mapError does the mirror image: it rewrites the Error and leaves an Ok alone — useful for wrapping a low-level message in a friendlier one. Result.bind chains a step that itself returns a Result, which is the engine of the next dive.

// map: transform the Ok, leave an Error alone
((Ok 5): Result<int,string>) |> Result.map (fun x -> x * 2)
// val it : Result<int,string> = Ok 10

// mapError: rewrite the Error, leave an Ok alone
((Error "boom"): Result<int,string>) |> Result.mapError (fun e -> "wrapped: " + e)
// val it : Result<int,string> = Error "wrapped: boom"

// bind: chain a step that itself returns a Result
((Ok 7): Result<int,string>) |> Result.bind (fun n -> if n > 0 then Ok (n * 10) else Error "neg")
// val it : Result<int,string> = Ok 70
C1.08.2 · part 3

One real wrinkle: the value restriction

The annotations : Result<int,string> in the previous block are deliberate. Written without them, the very first line is rejected by the compiler — and the reason is worth knowing, because it is a genuine corner of the type system, not a quirk of this example.

When Ok 5 is piped into Result.map at the top level of an FSI session, F# infers the success type as int but has nothing to pin the error type, so it would be a generic Result<int,'a>. The value restriction forbids a generic value at the top level — a value is read the same everywhere, and an open type parameter could break that — so the compiler reports FS0030.

// rejected: the error type is unconstrained at the top level
Ok 5 |> Result.map (fun x -> x * 2)
// error FS0030: Value restriction. The value 'it' has an inferred generic type

// fixed: annotate the Result, pinning both type parameters
((Ok 5): Result<int,string>) |> Result.map (fun x -> x * 2)
// val it : Result<int,string> = Ok 10
This surfaces only when both type parameters are left open, which at the top level means a bare Ok or Error with no surrounding context. Inside a function whose return type is known — like parse above, annotated string -> Result<int,string> — both parameters are already pinned, and no annotation is needed. The annotation is a fact about the REPL session, not about Result.

The idea

Failure is a value with a reason attached — read on the same footing as success.

F# today

Ok / Error, threaded with map · mapError · bind; the error type pinned by context.

Where this leads

Result.bind is the connector that makes a sequence of failable steps into one computation. When several steps each return a Result and the first Error should stop the rest, the shape has a name — the railway — and dive C1.08.3 builds one.

References

Sources

Related in this course