Maybe and Result in JavaScript: Railway-Oriented Programming
Maybe and Result make absence and failure explicit values. Build both in JavaScript, chain fallible steps with flatMap, and see when optional chaining wins.
- Course: JavaScript study plan
- Module: Functional patterns
- Kind: Lesson
- Reading time: 13 min
- Runtime: Node 16
What are Maybe and Result types?
Maybe and Result are small wrapper types that make absence and failure explicit values instead of undefined or a thrown exception. A Maybe is Some(value) or None; a Result is Ok(value) or Err(error), and it records why a step failed. Both offer map and flatMap, so a chain of steps that may each fail reads as a straight line.
Lesson
undefined and exceptions are JavaScript's answers to "there is no value" and "this failed", and both are invisible in a function's signature: nothing tells the caller that findUser may return undefined or that parseConfig may throw. Two small wrapper types make those outcomes explicit values: Maybe (a value that may be absent) and Result (a success or a typed failure). With map and flatMap on them, a chain of steps that might each fail becomes a straight line — the "railway": success rides one track, the first failure switches to the other and skips the rest. This lesson builds both types in a few dozen lines, shows the railway, relates them to things you already use (?., ??, arrays, promises), and is frank about when plain JavaScript idioms are the better choice.
Maybe
const Some = (value) => ({
isSome: true,
map: (f) => Some(f(value)),
flatMap: (f) => f(value), // f returns a Maybe — avoids Some(Some(x))
filter: (p) => (p(value) ? Some(value) : None),
getOrElse: () => value,
toString: () => `Some(${JSON.stringify(value)})`,
});
const None = {
isSome: false,
map: () => None, flatMap: () => None, filter: () => None,
getOrElse: (fallback) => fallback,
toString: () => "None",
};
const fromNullable = (v) => (v === null || v === undefined ? None : Some(v));
const city = fromNullable(users.find((u) => u.id === id))
.flatMap((u) => fromNullable(u.address))
.map((a) => a.city.toUpperCase())
.getOrElse("unknown");
map transforms the value if present; flatMap chains a step that itself may produce absence; None short-circuits everything; getOrElse leaves the type at the end. Compare with the built-in: users.find(…)?.address?.city?.toUpperCase() ?? "unknown". For a chain of property accesses, optional chaining wins — shorter, standard, no allocation. Maybe earns its place when steps are functions that may return nothing (lookups, parses, finds), when you want to filter in the middle, or when you pass the possibly-absent value around before deciding what to do with it.
Result
const Ok = (value) => ({
ok: true, value,
map: (f) => Ok(f(value)),
mapErr: () => Ok(value),
flatMap: (f) => f(value),
getOrElse: () => value,
match: ({ ok }) => ok(value),
});
const Err = (error) => ({
ok: false, error,
map: () => Err(error),
mapErr: (f) => Err(f(error)),
flatMap: () => Err(error),
getOrElse: (fallback) => fallback,
match: ({ err }) => err(error),
});
const attempt = (fn) => { try { return Ok(fn()); } catch (e) { return Err(e); } }; // exceptions → Result at the boundary
Result carries why it failed, which Maybe cannot. map transforms a success; mapErr transforms a failure (add context, translate to a user message); flatMap chains a step that may fail; match forces the caller to handle both cases. attempt wraps a throwing function — the adapter between exception-based code (JSON.parse) and Result-based code.
The railway
const parseJson = (text) => attempt(() => JSON.parse(text)).mapErr(() => "not valid JSON");
const requireObject = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? Ok(v) : Err("config must be an object"));
const requirePort = (cfg) => (Number.isInteger(cfg.port) && cfg.port > 0 && cfg.port < 65536 ? Ok(cfg) : Err(`port must be 1-65535, got ${JSON.stringify(cfg.port)}`));
const withDefaults = (cfg) => ({ retries: 3, ...cfg });
const loadConfig = (text) => parseJson(text).flatMap(requireObject).flatMap(requirePort).map(withDefaults);
loadConfig('{"port":8080}').match({ ok: (cfg) => start(cfg), err: (msg) => console.error(msg) });
Each step is a small pure function; flatMap connects those that can fail, map those that cannot; the first Err rides the failure track to the end untouched, so no step needs an if (!ok) return. Errors are values: collect them, count them, log them, return them from an API. Module 7's result objects ({ ok, value } | { ok, error }) are this pattern without the methods; adding map/flatMap is what removes the if (!r.ok) return r from every line.
Collecting many results
Result short-circuits at the first failure. For a form or a batch you want all failures: fold the results — const errors = results.filter((r) => !r.ok).map((r) => r.error); if none, Ok(results.map((r) => r.value)). That is a sequence/traverse in functional vocabulary; write it as a plain reduce. Promise.allSettled is the async version of the same idea.
You already use these shapes
- Arrays have
map/flatMap: an array is "zero or more values" andflatMapchains steps that return arrays. - Promises are
Resultfor the future:thenismap/flatMap(returned promises flatten),catchismapErr+recovery, a rejection rides the failure track past everythen. ?./??are a built-inMaybefor property paths.
The word for a type with a lawful map is functor; with flatMap (chain, bind) and a constructor (Some, Ok, Promise.resolve), monad. The laws just say map(identity) changes nothing and chaining composes associatively — properties you can check in a test and otherwise ignore. You do not need the vocabulary to use the pattern well.
Where to draw the line
Use Maybe/Result when: a pipeline has several steps that may each fail or return nothing; failures must be data (APIs, validation reports, batch jobs); or you want the signature to say that absence/failure is possible (TypeScript makes this compelling — Result<Config, ConfigError>). Prefer plain idioms when: a chain is property access (?.), a single call may fail (try/catch at the boundary), or the code is a script whose failure should simply stop it. A codebase where every function returns Result and every call site unwraps it has replaced exceptions with ceremony; the railway is for the stretches where several fallible steps line up.
Common mistakes
mapwhereflatMapwas needed —Some(Some(x)),Ok(Ok(x)), aResultinside aResult.- Unwrapping (
getOrElse,.value) in the middle of a chain instead of at the end. - Losing the error: converting
ErrtoNone, ormapErrto a message too early to distinguish causes. - Mixing exceptions and Results inside one layer — convert at the boundary with
attempt. - Building a
Maybefor a three-level property access that?.already handles. - Making the types heavier (classes, prototypes, generics) than the problem needs.
Interview angle
- "What problem do
Maybe/Resultsolve?" They make absence and failure explicit values withmap/flatMap, so chains of fallible steps read as straight lines and callers must handle both outcomes. - "
mapversusflatMapon these?"mapapplies a plain function to the inner value;flatMapapplies a function that itself returns the wrapper, avoiding nesting. - "How do promises relate?" A promise is a
Resultover time —thenmaps/chains, rejection is the failure track. - "When is optional chaining better than
Maybe?" For property paths;Maybefor chaining functions that may return nothing. - "How do you collect all errors instead of stopping at the first?" Fold the results: partition into failures and successes (
sequence/traverse).
Key takeaways
Maybe=Some(v)|Nonewithmap/flatMap/filter/getOrElse;Result=Ok(v)|Err(e)withmap/mapErr/flatMap/match;attemptconverts throws.- Railway:
flatMapfor fallible steps,mapfor safe ones; the first failure skips the rest; unwrap once at the end. - Collect all failures with a fold when partial results matter.
- Arrays, promises and
?./??are the same shapes you already use; the laws are just sanity checks. - Use the pattern for stretches of several fallible steps and for failures-as-data; plain idioms elsewhere.
Common questions
What is railway-oriented programming?
Railway-oriented programming chains steps that return a Result with flatMap, so success rides one track and the first Err switches to the other and skips every remaining step untouched. No step needs an if (!ok) return, the error arrives at the end as a value, and one match there handles both outcomes.
What is the difference between map and flatMap on Maybe or Result?
map applies a plain function to the wrapped value and wraps the result again. flatMap applies a function that itself returns a Maybe or Result and does not wrap it a second time. Using map with a fallible step produces Some(Some(x)) or Ok(Ok(x)), the sign that flatMap was needed.
When is optional chaining better than a Maybe type?
For a chain of property accesses: user?.address?.city ?? "unknown" is shorter, standard and allocates nothing. A Maybe earns its place when the steps are functions that may return nothing, such as lookups, parses and find calls, when you want to filter midway, or when the possibly absent value is passed around before a decision.
How are promises related to the Result type?
A promise is a Result spread over time. then works like map and flatMap, since a returned promise is flattened, catch acts as mapErr plus recovery, and a rejection rides the failure track past every then until something catches it.
How do you collect all validation errors instead of stopping at the first?
Run every check to get a list of results, then fold them: gather the errors from every failed result, and if there are none, return Ok with all the values. Functional libraries call this sequence or traverse; Promise.allSettled is the asynchronous version of the same idea.
Exercises
Chaining Maybes
Using the starter's Some/None/fromNullable, for each id on the input lines: find the user (fromNullable), flatMap to its address, map the city to upper case, filter cities longer than 3 characters, and print <id>: maybe=<String(maybe)> value=<getOrElse("unknown")> optionalChaining=<the same lookup with ?. and ??> name=<user's name via Maybe or nobody>. Finally print nested: map -> Some containing <Some(2).map((x) => Some(x)).getOrElse()> | flatMap -> <Some(2).flatMap((x) => Some(x))>.
Example: users [{"id":1,"name":"Ada","address":{"city":"London"}},{"id":2,"name":"Bob"},{"id":3,"name":"Cy","address":{"city":"Rio"}}] then 1, 2, 3, 9 →
1: maybe=Some("LONDON") value=LONDON optionalChaining=LONDON name=Ada
2: maybe=None value=unknown optionalChaining=unknown name=Bob
3: maybe=None value=unknown optionalChaining=RIO name=Cy
9: maybe=None value=unknown optionalChaining=unknown name=nobody
nested: map -> Some containing Some(2) | flatMap -> Some(2)
filter is what the Maybe chain can do that optional chaining cannot — Rio is too short.
A Result railway
With the starter's Ok/Err/attempt, build loadUser(text) = parseJson → requireObject → requireName → requireAge → normalise: parseJson wraps JSON.parse with attempt and maps the error to "not valid JSON"; requireObject rejects non-objects with "expected an object"; requireName needs a non-blank string ("name is required"); requireAge needs an integer 0–150 (age must be an integer 0-150, got <JSON>); normalise returns { name: trimmed lower-case, age, adult: age >= 18 }. Print <i>: ok <JSON> or <i>: err <message> per line, then a sequence over all results: all ok: <n> users or <n> failed: <messages joined by " | ">, then recovered=<first result's name or anonymous> mapErrDemo=<Err("x").mapErr(upper).error>.
Example: {"name":" Ada ","age":36}, {"name":"Bob","age":-1}, not json, [1,2], {"age":5} →
0: ok {"name":"ada","age":36,"adult":true}
1: err age must be an integer 0-150, got -1
2: err not valid JSON
3: err expected an object
4: err name is required
4 failed: age must be an integer 0-150, got -1 | not valid JSON | expected an object | name is required
recovered=ada mapErrDemo=XIn this module: Functional patterns
- Pure functions and immutability — why, and how far to take it
- Composition, currying and point-free style — building programs from small functions
- Folds, recursion and trampolines — reduce as the universal loop
- Memoization and laziness — computing once, and only when asked
- Maybe, Result and railway-oriented code — making absence and failure values (this lesson)
- Checkpoint — Functional patterns
← Memoization and laziness — computing once, and only when asked · Checkpoint — Functional patterns →