Result Type
Return either a success value or a failure reason, making failure explicit data rather than relying on exceptions.
· Last updated: 2026-10-25 · 14 min read
Overview
Suppose order processing parses a quantity string, validates the product and quantity, then applies a coupon. Any step can fail, but a throwing function's signature, such as decimal Price(OrderInput input), does not reveal that possibility, and the compiler does not warn about a missing catch. Returning null or -1 instead loses the reason.
A result type returns either a success value, Ok, or a failure reason, Err. The reason is a value such as an error code rather than an arbitrary sentence. Unlike an Option Type, which records only absence, it retains why processing failed, and the caller must handle both cases to retrieve the value.
Map applies a non-failing function to a success value, while Bind connects the next function that itself returns a result. Both pass an existing Err through without calling their function; a final Match handles success and failure separately to produce one value.
Flow
Parse, Validate, and ApplyDiscount can fail and are connected with Bind; the subtotal calculation uses Map. An Err skips the remaining calculations and proceeds to Match.
History
The standard Prelude in the Haskell 98 Report defines data Either a b = Left a | Right b. Either is a general type holding one of two types; the base documentation describes the convention of using Left for an error and Right for a correct value.12
Rust's standard library defines enum Result<T, E> { Ok(T), Err(E) } for returning and propagating errors. Its documentation recommends Result for expected, recoverable errors, and the #[must_use] attribute warns when a returned Result is ignored. Following RFC FS-1004, F# 4.1 added the Result type with Ok and Error to FSharp.Core.34
Scott Wlaschin's 2013 article "Railway oriented programming" explains this composition through success and failure tracks: once an input moves onto the failure track, it skips the remaining functions all the way to the end. He presented the subject at NDC London and other events in 2014. Microsoft's F# documentation notes that the community commonly calls monadic error handling with Result railway-oriented programming.567
Why use it?
A caller receiving Result<decimal> must go through Match to retrieve the decimal, making failure handling harder to overlook. The reason remains a value that can be printed with its stage and code or compared in tests, while each step stays an ordinary input-to-result function that can be tested independently.
Bind stops at the first failure. A form that should show every input problem instead needs independent checks whose failures are collected, as in applicative validation.
File, network, and database libraries still throw exceptions; the boundary around those calls catches and converts them to Err. Converting unrecoverable programming bugs such as null dereferences or out-of-range array access into Result merely adds failures that the caller has no way to handle.
C# has no built-in Result type, so the example defines one. It also lacks closed sum types that tell the compiler all possible cases: a switch handling Ok and Err still receives an exhaustiveness warning, CS8509. There is no equivalent of Rust's ? operator either, leaving chains of Map and Bind calls.
Example
The quantity parser returns a result. An abstract record Result<T> contains sealed record cases Ok and Err, while Failure holds an ErrorCode enum and a message. Because a switch cannot recognize those as the only cases, each case implements Map, Bind, and Match itself, taking functions as Func<> delegates.
using System;
using System.Globalization;
Console.WriteLine(Show(ParseQuantity("3").Map(q => q * 2)));
Console.WriteLine(Show(ParseQuantity("three").Map(q => q * 2)));
static Result<int> ParseQuantity(string text) =>
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var n)
? new Result<int>.Ok(n)
: new Result<int>.Err(new Failure(ErrorCode.NotANumber, $"'{text}' is not a whole number"));
static string Show(Result<int> result) =>
result.Match(value => $"Ok {value}", f => $"Err {f.Code}: {f.Message}");
// An enum fixes the failure kinds so the compiler catches misspellings.
enum ErrorCode { NotANumber }
sealed record Failure(ErrorCode Code, string Message);
// Hold either a success value or a failure reason.
abstract record Result<T>
{
public abstract Result<TOut> Map<TOut>(Func<T, TOut> f);
public abstract Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f);
public abstract TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr);
public sealed record Ok(T Value) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Ok(f(Value));
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => f(Value);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onOk(Value);
}
// Pass the same Failure through without calling the function.
public sealed record Err(Failure Failure) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Err(Failure);
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => new Result<TOut>.Err(Failure);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onErr(Failure);
}
}Ok 6
Err NotANumber: 'three' is not a whole number"three" becomes Err during parsing, so Map never runs q => q * 2 and the same failure is printed.
A broader example
Process seven inputs through Parse, Validate, and ApplyDiscount. Potentially failing steps use Bind, the subtotal uses Map, and Failure also carries a Step name to report where processing stopped. NumberStyles.None accepts quantity digits without signs or whitespace; CultureInfo.InvariantCulture keeps amount formatting independent of the machine's culture.
using System;
using System.Collections.Generic;
using System.Globalization;
var prices = new Dictionary<string, decimal> { ["PEN"] = 9.00m, ["BOOK"] = 25.00m };
OrderInput[] inputs =
[
new("PEN", "3", "SAVE10"),
new("BOOK", "2", ""),
new("PEN", "three", "SAVE10"),
new("CUP", "1", ""),
new("PEN", "0", ""),
new("PEN", "3", "FREE"),
new("CUP", "x", "FREE"),
];
foreach (var input in inputs)
{
// Bind connects potentially failing steps; Map connects non-failing calculations.
var result = Parse(input)
.Bind(order => Validate(order, prices))
.Map(line => new Subtotal(line.Quantity * line.UnitPrice, line.Coupon))
.Bind(ApplyDiscount);
var label = $"[{input.Sku}, \"{input.Quantity}\", \"{input.Coupon}\"]";
Console.WriteLine(result.Match(
total => $"ok {label} total {total.ToString("0.00", CultureInfo.InvariantCulture)}",
f => $"fail {label} {f.Step} {f.Code}: {f.Message}"));
}
static Result<Order> Parse(OrderInput input) =>
int.TryParse(input.Quantity, NumberStyles.None, CultureInfo.InvariantCulture, out var quantity)
? new Result<Order>.Ok(new Order(input.Sku, quantity, input.Coupon))
: Fail<Order>(Step.Parse, ErrorCode.NotANumber, $"quantity '{input.Quantity}' is not a whole number");
static Result<Line> Validate(Order order, IReadOnlyDictionary<string, decimal> prices)
{
if (!prices.TryGetValue(order.Sku, out var unitPrice))
return Fail<Line>(Step.Validate, ErrorCode.UnknownProduct, $"no product '{order.Sku}'");
if (order.Quantity is < 1 or > 100)
return Fail<Line>(Step.Validate, ErrorCode.OutOfRange, $"quantity {order.Quantity} is outside 1..100");
return new Result<Line>.Ok(new Line(order.Quantity, unitPrice, order.Coupon));
}
static Result<decimal> ApplyDiscount(Subtotal subtotal) => subtotal.Coupon switch
{
"" => new Result<decimal>.Ok(subtotal.Amount),
"SAVE10" => new Result<decimal>.Ok(Math.Round(subtotal.Amount * 0.90m, 2, MidpointRounding.AwayFromZero)),
_ => Fail<decimal>(Step.Discount, ErrorCode.UnknownCoupon, $"coupon '{subtotal.Coupon}' is not recognized"),
};
static Result<T> Fail<T>(Step step, ErrorCode code, string message) =>
new Result<T>.Err(new Failure(step, code, message));
sealed record OrderInput(string Sku, string Quantity, string Coupon);
sealed record Order(string Sku, int Quantity, string Coupon);
sealed record Line(int Quantity, decimal UnitPrice, string Coupon);
sealed record Subtotal(decimal Amount, string Coupon);
// Failure records what failed and at which step.
enum Step { Parse, Validate, Discount }
enum ErrorCode { NotANumber, UnknownProduct, OutOfRange, UnknownCoupon }
sealed record Failure(Step Step, ErrorCode Code, string Message);
abstract record Result<T>
{
public abstract Result<TOut> Map<TOut>(Func<T, TOut> f);
public abstract Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f);
public abstract TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr);
public sealed record Ok(T Value) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Ok(f(Value));
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => f(Value);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onOk(Value);
}
// After the first failure, later Map and Bind calls skip their functions.
public sealed record Err(Failure Failure) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Err(Failure);
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => new Result<TOut>.Err(Failure);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onErr(Failure);
}
}ok [PEN, "3", "SAVE10"] total 24.30
ok [BOOK, "2", ""] total 50.00
fail [PEN, "three", "SAVE10"] Parse NotANumber: quantity 'three' is not a whole number
fail [CUP, "1", ""] Validate UnknownProduct: no product 'CUP'
fail [PEN, "0", ""] Validate OutOfRange: quantity 0 is outside 1..100
fail [PEN, "3", "FREE"] Discount UnknownCoupon: coupon 'FREE' is not recognized
fail [CUP, "x", "FREE"] Parse NotANumber: quantity 'x' is not a whole numberThe last input has a bad product, quantity, and coupon, yet reports only Parse because Bind stops at that first failure. All processing happens in memory here; fetching a price from a database would need a boundary around that call to convert thrown exceptions to Err. Discounted amounts are rounded to two decimal places with MidpointRounding.AwayFromZero, without currency-specific rounding rules.
TypeScript example
TypeScript represents the result as a discriminated union with an ok field, so testing if (result.ok) narrows each branch to expose value on success and failure on failure. A string-literal union limits the error codes to the listed spellings. The example processes the same seven inputs.
type Step = "Parse" | "Validate" | "Discount";
// A string-literal union limits failure codes and catches misspellings at compile time.
type ErrorCode = "NotANumber" | "UnknownProduct" | "OutOfRange" | "UnknownCoupon";
type Failure = { step: Step; code: ErrorCode; message: string };
// A discriminated union uses ok to distinguish the cases.
type Result<T> = { ok: true; value: T } | { ok: false; failure: Failure };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(step: Step, code: ErrorCode, message: string): Result<T> =>
({ ok: false, failure: { step, code, message } });
// Return the same failure without calling the function.
const map = <T, U>(r: Result<T>, f: (value: T) => U): Result<U> => (r.ok ? ok(f(r.value)) : r);
const bind = <T, U>(r: Result<T>, f: (value: T) => Result<U>): Result<U> => (r.ok ? f(r.value) : r);
type OrderInput = { sku: string; quantity: string; coupon: string };
type Order = { sku: string; quantity: number; coupon: string };
type Line = { quantity: number; unitPriceCents: number; coupon: string };
type Subtotal = { cents: number; coupon: string };
// Use integer cents for amounts to avoid floating-point errors.
const pricesInCents = new Map<string, number>([["PEN", 900], ["BOOK", 2500]]);
function parse(input: OrderInput): Result<Order> {
if (!/^[0-9]+$/.test(input.quantity))
return fail("Parse", "NotANumber", `quantity '${input.quantity}' is not a whole number`);
return ok({ sku: input.sku, quantity: Number(input.quantity), coupon: input.coupon });
}
function validate(order: Order): Result<Line> {
const unitPriceCents = pricesInCents.get(order.sku);
if (unitPriceCents === undefined) return fail("Validate", "UnknownProduct", `no product '${order.sku}'`);
if (order.quantity < 1 || order.quantity > 100)
return fail("Validate", "OutOfRange", `quantity ${order.quantity} is outside 1..100`);
return ok({ quantity: order.quantity, unitPriceCents, coupon: order.coupon });
}
function applyDiscount(subtotal: Subtotal): Result<number> {
if (subtotal.coupon === "") return ok(subtotal.cents);
if (subtotal.coupon === "SAVE10") return ok(Math.round((subtotal.cents * 90) / 100));
return fail("Discount", "UnknownCoupon", `coupon '${subtotal.coupon}' is not recognized`);
}
const inputs: OrderInput[] = [
{ sku: "PEN", quantity: "3", coupon: "SAVE10" },
{ sku: "BOOK", quantity: "2", coupon: "" },
{ sku: "PEN", quantity: "three", coupon: "SAVE10" },
{ sku: "CUP", quantity: "1", coupon: "" },
{ sku: "PEN", quantity: "0", coupon: "" },
{ sku: "PEN", quantity: "3", coupon: "FREE" },
{ sku: "CUP", quantity: "x", coupon: "FREE" },
];
for (const input of inputs) {
const line = bind(parse(input), validate);
const subtotal = map(line, (l) => ({ cents: l.quantity * l.unitPriceCents, coupon: l.coupon }));
const result = bind(subtotal, applyDiscount);
const label = `[${input.sku}, "${input.quantity}", "${input.coupon}"]`;
// Checking result.ok narrows each branch to value or failure.
if (result.ok) console.log(`ok ${label} total ${(result.value / 100).toFixed(2)}`);
else console.log(`fail ${label} ${result.failure.step} ${result.failure.code}: ${result.failure.message}`);
}ok [PEN, "3", "SAVE10"] total 24.30
ok [BOOK, "2", ""] total 50.00
fail [PEN, "three", "SAVE10"] Parse NotANumber: quantity 'three' is not a whole number
fail [CUP, "1", ""] Validate UnknownProduct: no product 'CUP'
fail [PEN, "0", ""] Validate OutOfRange: quantity 0 is outside 1..100
fail [PEN, "3", "FREE"] Discount UnknownCoupon: coupon 'FREE' is not recognized
fail [CUP, "x", "FREE"] Parse NotANumber: quantity 'x' is not a whole numberAmounts use integer cents to avoid number's floating-point errors, and the output matches C#. A regular expression accepts digits only for quantities, so an extremely long digit string is rejected by the range check after conversion with Number.
See also
Example environment: each C# example is a separate .NET 10 console program; compile TypeScript for ES2022 or later, then run the JavaScript with Node.js.
Footnotes
- The Haskell 98 Report: Standard Prelude. Definition of Either. ↩
- base 4.22.0.0, Data.Either. Description of Either. ↩
- Rust standard library, Module std::result. Module overview, the #[must_use] attribute, and the ? operator. ↩
- F# RFC FS-1004, Result type. Implemented in F# 4.1. ↩
- Wlaschin, Scott. Railway oriented programming (2013). Part 2 of A recipe for a functional app. ↩
- Wlaschin, Scott. Railway Oriented Programming. Talk videos and slides. NDC London 2014, NDC Oslo 2014, Functional Programming eXchange 2014. ↩
- Microsoft Learn, F# Results. Remarks section. ↩