Theme 01 · Type-Safety Maximalist, But Pragmatic
Typed errors
Explanation
Typed errors
Plain Human Explanation
Some failures are normal product outcomes. A card can be declined, an email can already be taken, and a file can be too large. Typed errors give those expected failures names so callers know what happened and what they can do next.
Technical Explanation
Return a result type for expected failures, usually a union such as { ok: true; value: T } | { ok: false; error: SomeError }. Reserve thrown exceptions for unexpected programmer mistakes or infrastructure failures that the immediate caller cannot handle. The error variants should be specific enough for UI, logging, retries, and support decisions.
Why It Matters
- User impact: people get the right message or next step for recoverable failures.
- Product behavior: expected failures stay part of the function contract instead of surprise control flow.
- Risk: generic throws,
null, and booleans force callers to guess whether to retry, show a form error, or escalate. - Decision point: use this when a caller can make a meaningful decision based on the failure type.
The Core Move
Name expected failures in the return type. Make success and known failure equally visible at the call site.
Small Example
Typed errors: Small Example
Bad TypeScript Example
export function reserveUsername(username: string, taken: Set<string>) {
if (username.length < 3) throw new Error("Bad username");
if (taken.has(username)) return null;
return username;
}
export function reserveUsername(
username: string,
taken: Set<string>,
) {
if (username.length < 3)
throw new Error("Bad username");
if (taken.has(username)) return null;
return username;
}
Good TypeScript Example
type ReserveUsernameError =
| { kind: "too-short"; minimumLength: number }
| { kind: "already-taken" };
type ReserveUsernameResult =
| { ok: true; username: string }
| { ok: false; error: ReserveUsernameError };
export function reserveUsername(username: string, taken: Set<string>): ReserveUsernameResult {
if (username.length < 3) {
return { ok: false, error: { kind: "too-short", minimumLength: 3 } };
}
if (taken.has(username)) {
return { ok: false, error: { kind: "already-taken" } };
}
return { ok: true, username };
}
type ReserveUsernameError =
| {
kind: "too-short";
minimumLength: number;
}
| {
kind: "already-taken";
};
type ReserveUsernameResult =
| {
ok: true;
username: string;
}
| {
ok: false;
error: ReserveUsernameError;
};
export function reserveUsername(
username: string,
taken: Set<string>,
): ReserveUsernameResult {
if (username.length < 3) {
return {
ok: false,
error: {
kind: "too-short",
minimumLength: 3,
},
};
}
if (taken.has(username)) {
return {
ok: false,
error: {
kind: "already-taken",
},
};
}
return {
ok: true,
username,
};
}
What Changed
- The bad version mixes throws and
null, so callers have to discover failure behavior by reading the body. - The good version lists expected failures in the return type.
- UI code can now show different messages for a short username and a taken username.
Realistic Example
Typed errors: Realistic Example
This example uses a billing action where several failures are expected and user-visible.
Bad TypeScript Example
type PaymentGateway = {
charge(invoiceId: string): Promise<{
success: boolean;
message: string;
}>;
};
export async function chargeInvoice(invoiceId: string, gateway: PaymentGateway) {
const charge = await gateway.charge(invoiceId);
if (!charge.success) {
throw new Error(charge.message);
}
return true;
}
type PaymentGateway = {
charge(invoiceId: string): Promise<{
success: boolean;
message: string;
}>;
};
export async function chargeInvoice(
invoiceId: string,
gateway: PaymentGateway,
) {
const charge =
await gateway.charge(invoiceId);
if (!charge.success) {
throw new Error(charge.message);
}
return true;
}
Good TypeScript Example
type ChargeInvoiceError =
| { kind: "card-declined"; declineCode: string }
| { kind: "invoice-already-paid" }
| { kind: "payment-provider-unavailable"; retryAfterSeconds: number };
type ChargeInvoiceResult =
| { ok: true; receiptId: string }
| { ok: false; error: ChargeInvoiceError };
type PaymentGateway = {
charge(invoiceId: string): Promise<
| { status: "paid"; receiptId: string }
| { status: "declined"; declineCode: string }
| { status: "already-paid" }
| { status: "unavailable" }
>;
};
export async function chargeInvoice(invoiceId: string, gateway: PaymentGateway): Promise<ChargeInvoiceResult> {
const charge = await gateway.charge(invoiceId);
if (charge.status === "paid") return { ok: true, receiptId: charge.receiptId };
if (charge.status === "declined") {
return { ok: false, error: { kind: "card-declined", declineCode: charge.declineCode } };
}
if (charge.status === "already-paid") return { ok: false, error: { kind: "invoice-already-paid" } };
return { ok: false, error: { kind: "payment-provider-unavailable", retryAfterSeconds: 60 } };
}
type ChargeInvoiceError =
| {
kind: "card-declined";
declineCode: string;
}
| {
kind: "invoice-already-paid";
}
| {
kind: "payment-provider-unavailable";
retryAfterSeconds: number;
};
type ChargeInvoiceResult =
| {
ok: true;
receiptId: string;
}
| {
ok: false;
error: ChargeInvoiceError;
};
type PaymentGateway = {
charge(invoiceId: string): Promise<
| {
status: "paid";
receiptId: string;
}
| {
status: "declined";
declineCode: string;
}
| {
status: "already-paid";
}
| {
status: "unavailable";
}
>;
};
export async function chargeInvoice(
invoiceId: string,
gateway: PaymentGateway,
): Promise<ChargeInvoiceResult> {
const charge =
await gateway.charge(invoiceId);
if (charge.status === "paid")
return {
ok: true,
receiptId: charge.receiptId,
};
if (charge.status === "declined") {
return {
ok: false,
error: {
kind: "card-declined",
declineCode: charge.declineCode,
},
};
}
if (charge.status === "already-paid")
return {
ok: false,
error: {
kind: "invoice-already-paid",
},
};
return {
ok: false,
error: {
kind: "payment-provider-unavailable",
retryAfterSeconds: 60,
},
};
}
What Changed
- The bad version hides product failures inside a generic exception message.
- The good version distinguishes user-fixable, harmless, and retryable failures.
- Callers can decide whether to show a card message, ignore a duplicate payment, or schedule a retry.
System Example
Typed errors: System Example
At system scale, typed errors help APIs, jobs, and logs make the same decision about a failure.
Larger System-Level Bad TypeScript Example
type ImportPorts = {
files: {
readCsv(fileId: string): Promise<unknown[]>;
};
customers: {
upsertMany(rows: unknown[]): Promise<void>;
};
log: {
error(event: string, payload: Record<string, unknown>): void;
};
};
export async function importCustomers(fileId: string, ports: ImportPorts) {
try {
const rows = await ports.files.readCsv(fileId);
await ports.customers.upsertMany(rows);
return { status: 200 };
} catch (error) {
ports.log.error("customer import failed", { error });
return { status: 500 };
}
}
type ImportPorts = {
files: {
readCsv(
fileId: string,
): Promise<unknown[]>;
};
customers: {
upsertMany(
rows: unknown[],
): Promise<void>;
};
log: {
error(
event: string,
payload: Record<string, unknown>,
): void;
};
};
export async function importCustomers(
fileId: string,
ports: ImportPorts,
) {
try {
const rows =
await ports.files.readCsv(fileId);
await ports.customers.upsertMany(rows);
return {
status: 200,
};
} catch (error) {
ports.log.error(
"customer import failed",
{
error,
},
);
return {
status: 500,
};
}
}
Larger System-Level Good TypeScript Example
type ImportCustomersError = { kind: "file-not-found"; fileId: string } | { kind: "invalid-csv"; row: number; message: string } | { kind: "temporary-store-failure"; retryAfterSeconds: number };
type ImportCustomersResult = { ok: true; importedCount: number } | { ok: false; error: ImportCustomersError };
type Customer = {
email: string;
name: string;
};
type ImportPorts = {
files: {
readCsv(fileId: string): Promise<{ ok: true; rows: unknown[] } | { ok: false }>;
};
customers: {
upsertMany(customers: Customer[]): Promise<{ ok: true } | { ok: false }>;
};
};
function parseCustomerRows(rows: unknown[]): { ok: true; customers: Customer[] } | { ok: false; row: number; message: string } {
const customers: Customer[] = [];
for (const [index, row] of rows.entries()) {
if (typeof row !== "object" || row === null) {
return { ok: false, row: index + 1, message: "Row is not an object" };
}
const value = row as Record<string, unknown>;
if (typeof value.email !== "string" || typeof value.name !== "string") {
return { ok: false, row: index + 1, message: "Missing email or name" };
}
customers.push({ email: value.email, name: value.name });
}
return { ok: true, customers };
}
export async function importCustomers(fileId: string, ports: ImportPorts): Promise<ImportCustomersResult> {
const file = await ports.files.readCsv(fileId);
if (!file.ok) return { ok: false, error: { kind: "file-not-found", fileId } };
const parsed = parseCustomerRows(file.rows);
if (!parsed.ok) return { ok: false, error: { kind: "invalid-csv", row: parsed.row, message: parsed.message } };
const saved = await ports.customers.upsertMany(parsed.customers);
if (!saved.ok) return { ok: false, error: { kind: "temporary-store-failure", retryAfterSeconds: 30 } };
return { ok: true, importedCount: parsed.customers.length };
}
type ImportCustomersError =
| {
kind: "file-not-found";
fileId: string;
}
| {
kind: "invalid-csv";
row: number;
message: string;
}
| {
kind: "temporary-store-failure";
retryAfterSeconds: number;
};
type ImportCustomersResult =
| {
ok: true;
importedCount: number;
}
| {
ok: false;
error: ImportCustomersError;
};
type Customer = {
email: string;
name: string;
};
type ImportPorts = {
files: {
readCsv(fileId: string): Promise<
| {
ok: true;
rows: unknown[];
}
| {
ok: false;
}
>;
};
customers: {
upsertMany(
customers: Customer[],
): Promise<
| {
ok: true;
}
| {
ok: false;
}
>;
};
};
function parseCustomerRows(
rows: unknown[],
):
| {
ok: true;
customers: Customer[];
}
| {
ok: false;
row: number;
message: string;
} {
const customers: Customer[] = [];
for (const [
index,
row,
] of rows.entries()) {
if (
typeof row !== "object" ||
row === null
) {
return {
ok: false,
row: index + 1,
message: "Row is not an object",
};
}
const value = row as Record<
string,
unknown
>;
if (
typeof value.email !== "string" ||
typeof value.name !== "string"
) {
return {
ok: false,
row: index + 1,
message: "Missing email or name",
};
}
customers.push({
email: value.email,
name: value.name,
});
}
return {
ok: true,
customers,
};
}
export async function importCustomers(
fileId: string,
ports: ImportPorts,
): Promise<ImportCustomersResult> {
const file =
await ports.files.readCsv(fileId);
if (!file.ok)
return {
ok: false,
error: {
kind: "file-not-found",
fileId,
},
};
const parsed = parseCustomerRows(
file.rows,
);
if (!parsed.ok)
return {
ok: false,
error: {
kind: "invalid-csv",
row: parsed.row,
message: parsed.message,
},
};
const saved =
await ports.customers.upsertMany(
parsed.customers,
);
if (!saved.ok)
return {
ok: false,
error: {
kind: "temporary-store-failure",
retryAfterSeconds: 30,
},
};
return {
ok: true,
importedCount: parsed.customers.length,
};
}
What Changed
- The bad version converts every failure into a generic 500 response.
- The good version keeps user-fixable CSV errors separate from retryable storage failures.
- API handlers, background jobs, and support logs can all branch on the same error names.
When To Use It
Typed errors: When To Use It
Use This When
- The caller can recover, retry, show a specific message, or choose a different path.
- The same operation has several expected failure modes.
- Logs, API responses, jobs, or support tools need stable error names.
Avoid This When
- The failure is a programmer bug that should crash loudly during development.
- The immediate caller cannot do anything useful with the detail.
- The error type becomes a catch-all list of unrelated infrastructure problems.
Tradeoffs
Typed errors make call sites a little more explicit. The benefit is that expected failures stop hiding in exception text. Keep variants few, named around decisions, and easy for callers to handle.
Related Concepts
- Parsed inputs
- Structured errors
- Errors-as-values thinking
Practice Prompt
Typed errors: Practice Prompt
Beginner Exercise
Find a function that returns null, false, or throws for an expected product failure. Name the specific failures a caller might care about.
Intermediate Exercise
Replace the return shape with a result union. Update one caller to handle each error variant explicitly.
Stretch Exercise
Map the typed error to two different outputs, such as an API response and a background retry decision.
Reflection Question
Which failures should be part of the function contract, and which ones should still be unexpected exceptions?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.