TypeScript Result/Either types for type-safe error handling, railway-oriented programming patterns, error as values
Quick Guide: A
Result<T, E>is a discriminated union onok, so TypeScript refuses to readvalueuntil the caller has checked. That moves a function's failure modes into its signature, where an exception hides them. Use it for expected failures — validation, parsing, requests — and keep exceptions for bugs and for conditions nothing downstream can act on. A custom implementation is about forty lines and the recommended default; the whole surface is in this skill.
Detailed Resources:
Promise<Result>, async chaining, retry, converting a promiseok, err, map, flatMap, match,
tryCatch. examples/core.md is the whole file.Promise<Result<T, E>> and the awaiting is the
caller's; see examples/async.md.<critical_requirements>
Check result.ok before reading value or error. The union narrows only through that check, so TypeScript will refuse either access until it is made — and a runtime undefined is what a bypassed check produces.
Wrap every throwing call inside a Result-returning function in tryCatch. JSON.parse and its kin throw past the return type, so one unwrapped call makes the signature a lie and the caller's exhaustive handling incomplete.
Give each error a discriminant field — code or type — rather than typing it as Error or string. The discriminant is what lets the caller switch and lets TypeScript check the switch is exhaustive; a bare message can only be displayed.
Chain with flatMap where each step returns a Result. The error type unions itself and the first failure short-circuits the rest, which is what nested if (result.ok) blocks are reimplementing by hand.
Do something with every Result you receive. A discarded one is a failure that never happened as far as the rest of the program is concerned, and no type error marks it.
</critical_requirements>
Auto-detection: Result type, Either type, ok err, railway-oriented programming, error as value, flatMap andThen, tryCatch, unwrapOr, combineWithAllErrors, discriminated union error, typed errors
Applies to:
Handled elsewhere:
An exception is invisible control flow: it leaves no trace in the type, so the only way to know a function throws is to read it or to be surprised in production. A Result puts the same information in the signature, where the compiler enforces it.
The cost is real — every caller handles or propagates, and the error union grows as a chain lengthens. That is why the boundary matters: convert throwing code to Results on the way in, and convert Results to whatever the outside world wants on the way out. In between, nothing throws.
The railway: success runs the main line, and the first error switches to the parallel one, where every later step is skipped until something explicitly handles it.
parseNumber validatePositive double
OK ─────────────────────────────────────────────> success
↘ ↘
ERR ────────────────────────────> failure
</philosophy>
<decision_framework>
Can the caller do something about this failure?
├─ NO — it is a bug or a condition nothing can act on → throw
│ ├─ Index out of bounds, invalid internal state
│ └─ Missing startup configuration, unreachable database at boot
└─ YES → What does the failure need to carry?
├─ Nothing but its own absence → T | null
├─ A reason the caller branches on → Result<T, E>
└─ Several distinct reasons → Result<T, E> with a discriminated E
A Result<User, NotFoundError> whose error carries only code: "NOT_FOUND" is a nullable wearing a
costume. Reach for the Result when the caller's next action differs by reason.
Fail fast or collect everything: one invalid field in a form is not a reason to hide the other four, so form validation collects; a chain where step two consumes step one's output has nothing to collect and short-circuits.
Returning a value also costs far less than throwing one, because a thrown error captures a stack trace and unwinds; reference.md carries the measured comparison. That is a tiebreaker on a hot path rather than a reason on its own.
</decision_framework>
ok as the discriminant, readonly throughout, never on the other side so inference stays clean.
export type Result<T, E = Error> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
Full code: examples/core.md
map and mapErrorEach transforms one side and passes the other through untouched, which is what makes them safe to apply to a Result you have not checked.
export const map = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => U,
): Result<U, E> => (result.ok ? ok(fn(result.value)) : result);
export const mapError = <T, E, F>(
result: Result<T, E>,
fn: (error: E) => F,
): Result<T, F> => (result.ok ? result : err(fn(result.error)));
mapError is where context is added — the operation that failed, the input that caused it.
flatMap for chainingThe step returns a Result of its own, so the error types union and the first failure ends the chain.
export const flatMap = <T, U, E, F>(
result: Result<T, E>,
fn: (value: T) => Result<U, F>,
): Result<U, E | F> => (result.ok ? fn(result.value) : result);
const parsed = flatMap(parseNumber(input), validatePositive);
// Result<number, ParseError | ValidationError>
Full code: examples/core.md
tryCatch at the boundaryThrowing code is converted where it enters, and the error is mapped to this domain's type in the same call.
export const tryCatch = <T, E>(
fn: () => T,
onError: (error: unknown) => E,
): Result<T, E> => {
try {
return ok(fn());
} catch (error) {
return err(onError(error));
}
};
const parsed = tryCatch(
() => JSON.parse(json) as Config,
(error): ParseError => ({
code: "PARSE_ERROR",
message: String(error),
input: json,
}),
);
A JSON.parse left unwrapped inside a Result-returning function is the commonest way the signature
stops being true.
Full code: examples/core.md
match for exhaustive handlingBoth sides answered in one expression, which is what makes it the natural converter at an outbound boundary.
export const match = <T, E, U>(
result: Result<T, E>,
handlers: { ok: (value: T) => U; err: (error: E) => U },
): U => (result.ok ? handlers.ok(result.value) : handlers.err(result.error));
const response = match(loadUser(id), {
ok: (user) => ({ status: 200, body: user }),
err: (error) => toHttpResponse(error),
});
Full code: examples/core.md
Each variant carries what its own handler needs, and the union names every way the function fails.
type UserError =
| { readonly code: "NOT_FOUND"; readonly userId: string }
| {
readonly code: "VALIDATION_ERROR";
readonly field: string;
readonly message: string;
}
| { readonly code: "NETWORK_ERROR"; readonly statusCode: number };
if (!result.ok) {
switch (result.error.code) {
case "NOT_FOUND":
return showMissing(result.error.userId);
case "VALIDATION_ERROR":
return highlightField(result.error.field);
case "NETWORK_ERROR":
return offerRetry();
}
}
Adding a variant reddens every switch that does not handle it, which is the whole return on the discriminant.
Full code: examples/core.md
Fail-fast returns the first error; collect-all returns every one.
export const combine = <T, E>(results: Result<T, E>[]): Result<T[], E> => {
const values: T[] = [];
for (const result of results) {
if (!result.ok) return result;
values.push(result.value);
}
return ok(values);
};
Full code: examples/combining.md
</patterns><red_flags>
Breaks at runtime:
result.value without checking ok — undefined at the point of use, and a non-null assertion or a cast is what got it past the compiler.instanceof Error — a plain discriminated object is not one, so an instanceof check silently takes the wrong branch.Surprising behaviour:
map with a function that itself returns a Result gives Result<Result<T, F>, E> — it type-checks, and the caller has to unwrap twice to reach anything. That doubling is what flatMap exists to prevent.flatMap unions error types, so a long chain ends with an error union nobody wants to handle — narrow it with mapError at the point the extra variants stop mattering.Result<void, E> rather than Result<undefined, E> for an operation with no success value; the second forces callers to name a value that does not exist.Promise<Result<T, E>> is truthy while it is pending, so an unawaited one passes an ok check that means nothing.combineWithAllErrors returning an array whose first element is all anyone displays wastes the work; either show them all or fail fast.</red_flags>
npx skills add agents-inc/web-error-handling-result-types下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer