Typed Errors in Data-Intensive Apps
- 11 min read
The Problem With throw in Data Paths
Data-intensive features fail in boring, predictable ways:
- The same event arrives twice (at-least-once delivery)
- A batch is already running for this tenant
- 3 rows in a 10_000-row import are invalid
- The warehouse times out; the API is fine
- Two operators click “Rebuild report” at the same time
Those are not bugs. They are part of the product.
Yet a lot of TypeScript services still look like this:
// ❌ Expected failures look the same as crashes
async function importEvents(batch: Batch): Promise<ImportSummary> {
if (await isDuplicate(batch.id)) {
throw new Error("duplicate batch");
}
// ...
throw new Error("warehouse timeout");
}
By the time this hits React, everything is a toast: “Something went wrong.”
Ops cannot tell a conflict from an outage. Retries hammer the wrong layer. Types lie - the signature says Promise<ImportSummary>, but the real contract is “summary or six invisible failure modes.”
Best practice: model expected failures as data (Result), reserve exceptions for unexpected bugs and broken invariants.
In TypeScript, the library I reach for is neverthrow - small, explicit ok / err, solid ResultAsync helpers, and no need to adopt a full effect runtime on day one.
Expected vs Unexpected (Draw the Line)
| Kind | Examples | Model as |
|---|---|---|
| Expected | duplicate idempotency key, validation, not found, conflict, forbidden, upstream timeout | Result / ResultAsync |
| Unexpected | null where the schema guarantees a row after a txn, programmer bug, corrupted state | throw → central logger + 500 |
If a product manager would put it in a flowchart (“if duplicate, show X”), it is expected.
If it means “our code is wrong,” throw.
The Running Example: Idempotent Event Import
A pattern every analytics, billing, or ops pipeline hits:
Accept a batch of events.
Deduplicate byeventId.
Fail closed on structurally invalid rows (or report partial failure when product policy allows).
Refuse to start if the samebatchIdis already in progress.
Surface warehouse timeouts as a typed failure the UI can retry safely.
Client (ops UI / partner API)
│ POST /imports
▼
HTTP adapter ── maps Result → status + { code, message, details? }
│
▼
importBatch() ── domain: ResultAsync<Summary, ImportError>
│
┌────┼────┐
▼ ▼ ▼
DB blob warehouse
Domain code never sets res.status(409). HTTP does that once.
Error Codes as a Closed Set
Start small. Expand when a real UI branch or metric needs a new code.
// import-errors.ts
export type ImportErrorCode =
| "VALIDATION"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "UPSTREAM_TIMEOUT"
| "UPSTREAM_FAILURE"
| "PARTIAL_FAILURE";
export type ImportError = {
code: ImportErrorCode;
message: string;
/** Safe for clients - row numbers, field names, retry hints. Never SQL or stacks. */
details?: Record<string, unknown>;
};
Why codes beat free-text Error.message:
- UI switches on
code, not English string matching - Metrics:
import.errors{code="CONFLICT"} - Retries: only
UPSTREAM_TIMEOUTauto-retries;CONFLICTdoes not
Domain Layer With neverthrow
Types
import { err, ok, Result, ResultAsync } from "neverthrow";
import type { ImportError } from "./import-errors";
type EventRow = {
eventId: string;
tenantId: string;
name: string;
ts: string; // ISO-8601
props: Record<string, unknown>;
};
type ImportBatchInput = {
batchId: string;
tenantId: string;
rows: EventRow[];
};
type ImportSummary = {
batchId: string;
accepted: number;
duplicates: number;
rejected: number;
};
type ImportDeps = {
assertActorCanWrite: (tenantId: string) => ResultAsync<void, ImportError>;
importAtomically: (
input: ImportBatchInput,
) => ResultAsync<
| { state: "completed"; summary: ImportSummary }
| { state: "running" },
ImportError
>;
};
Sync validation first
function isIsoInstant(value: string): boolean {
const match = value.match(
/^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d)$/,
);
if (!match || Number.isNaN(Date.parse(value))) return false;
const [, year, month, day] = match.map(Number);
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
return day <= daysInMonth;
}
function validateRows(rows: EventRow[]): Result<EventRow[], ImportError> {
if (rows.length === 0) {
return err({
code: "VALIDATION",
message: "Batch must include at least one event",
});
}
const bad: { index: number; reason: string }[] = [];
rows.forEach((row, index) => {
if (!row.eventId?.trim()) bad.push({ index, reason: "eventId required" });
if (!row.name?.trim()) bad.push({ index, reason: "name required" });
if (!isIsoInstant(row.ts)) {
bad.push({ index, reason: "ts must be an ISO-8601 instant with timezone" });
}
});
if (bad.length === 0) return ok(rows);
// Policy A (this article's default): fail closed on structural trash
return err({
code: "VALIDATION",
message: `${bad.length} row(s) failed validation`,
details: { bad: bad.slice(0, 20), totalBad: bad.length },
});
}
Async pipeline
export function importBatch(
input: ImportBatchInput,
deps: ImportDeps,
): ResultAsync<ImportSummary, ImportError> {
// Result.asyncAndThen: sync Result → ResultAsync chain
return validateRows(input.rows).asyncAndThen((rows) =>
deps
.assertActorCanWrite(input.tenantId)
.andThen(() => deps.importAtomically({ ...input, rows }))
.andThen((result) => {
if (result.state === "running") {
return err({
code: "CONFLICT",
message: "This batch is still running",
details: { batchId: input.batchId },
});
}
// A retry after the original response was lost returns the stored summary.
return ok(result.summary);
}),
);
}
The adapter enforces idempotency in the database, not with a read-then-insert race. Put UNIQUE (tenant_id, event_id) on events and UNIQUE (tenant_id, batch_id) on batches. In one transaction, claim or lock the batch row, collapse duplicate IDs inside the request, insert with ON CONFLICT DO NOTHING, and persist the final summary before commit:
WITH deduped AS (
SELECT DISTINCT ON (tenant_id, event_id) *
FROM jsonb_to_recordset($1) AS r(
tenant_id text, event_id text, name text, ts timestamptz, props jsonb
)
), inserted AS (
INSERT INTO events (tenant_id, event_id, name, ts, props)
SELECT tenant_id, event_id, name, ts, props FROM deduped
ON CONFLICT (tenant_id, event_id) DO NOTHING
RETURNING 1
)
SELECT count(*) AS accepted FROM inserted;
duplicates = input row count - accepted, so it includes duplicates already stored and repeated IDs in this batch. A running batch is a 409; a completed batch returns its stored summary. Failed transactions roll back and can be reclaimed according to a documented lease/failed-state policy. Because events and the completed summary commit atomically, a timeout after commit is safe: retrying the same batchId returns that summary and cannot insert an event twice.
Best practices baked in:
Resultfor pure checks,ResultAsyncfor IO - one style at the service boundary.- Injected deps - domain stays unit-testable; adapters own SQL/HTTP.
- Idempotency is a branch - duplicates increment a counter; they do not
throw. - Validation carries row indexes - UI can highlight offenders.
andThenshort-circuits - firsterrstops the chain; no nestedtry/catchladders.
Wrapping unsafe IO (warehouse / DB drivers)
At the adapter edge, convert throwing clients once:
import { ResultAsync } from "neverthrow";
import type { ImportError } from "./import-errors";
export function insertEvents(
rows: EventRow[],
): ResultAsync<number, ImportError> {
return ResultAsync.fromPromise(
Promise.resolve().then(() => warehouse.bulkInsert(rows)), // catches sync throw and rejection
(cause): ImportError => {
const message = cause instanceof Error ? cause.message : "insert failed";
if (/timeout/i.test(message)) {
return {
code: "UPSTREAM_TIMEOUT",
message: "Warehouse timed out while inserting events",
details: { retryable: true },
};
}
return {
code: "UPSTREAM_FAILURE",
message: "Warehouse insert failed",
details: { retryable: false },
};
},
).map((result) => result.insertedCount);
}
fromPromise is the on-ramp from the throwing world. Inside the domain, stay on Result / ResultAsync.
Map to HTTP Once
import type { Request, Response } from "express";
import { importBatch } from "./import-batch";
import type { ImportErrorCode } from "./import-errors";
const statusByCode: Record<ImportErrorCode, number> = {
VALIDATION: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UPSTREAM_TIMEOUT: 504,
UPSTREAM_FAILURE: 502,
// 422 is a common choice; 207 if you want multi-status semantics
PARTIAL_FAILURE: 422,
};
export async function postImport(req: Request, res: Response) {
// Optional: Zod at the edge → VALIDATION before domain runs
const result = await importBatch(req.body, depsFor(req));
if (result.isOk()) {
return res.status(200).json({ ok: true, data: result.value });
}
const { code, message, details } = result.error;
return res.status(statusByCode[code]).json({
ok: false,
error: { code, message, details },
});
}
Stable envelope:
{
"ok": false,
"error": {
"code": "CONFLICT",
"message": "This batch is still running",
"details": { "batchId": "b_123" }
}
}
Clients never parse Error: duplicate batch. They branch on error.code.
UI: Branch on Codes, Not Vibes
type ApiError = {
code:
| "VALIDATION"
| "CONFLICT"
| "UPSTREAM_TIMEOUT"
| "UPSTREAM_FAILURE"
| "FORBIDDEN"
| string;
message: string;
details?: { bad?: { index: number; reason: string }[] };
};
export function ImportErrorPanel({ error }: { error: ApiError }) {
switch (error.code) {
case "CONFLICT":
return (
<p>
This batch is already running. Wait for it to finish or open the
existing import instead of resubmitting with a new id.
</p>
);
case "VALIDATION":
return (
<div>
<p>{error.message}</p>
<ul>
{(error.details?.bad ?? []).map((row) => (
<li key={row.index}>
Row {row.index + 1}: {row.reason}
</li>
))}
</ul>
</div>
);
case "UPSTREAM_TIMEOUT":
return (
<p>
The warehouse timed out. Safe to retry - inserts are idempotent on{" "}
<code>eventId</code>.
</p>
);
case "UPSTREAM_FAILURE":
return <p>The warehouse rejected the import. Try again later or contact support.</p>;
case "FORBIDDEN":
return <p>You cannot import into this tenant.</p>;
default:
return <p>Import failed. If it keeps happening, contact support.</p>;
}
}
That is the fullstack payoff: one vocabulary from warehouse adapter → HTTP → screen.
Partial Failure (The Data-Intensive Special)
Sometimes policy is: accept good rows, report bad ones.
type PartialImport = {
batchId: string;
accepted: number;
rejected: { index: number; reason: string }[];
};
// Option A - success with rejected[] (operators still act on bad rows)
ok({ batchId, accepted: 9970, rejected: [/* ... */] satisfies PartialImport["rejected"] });
// Option B - error when SLAs require all-or-nothing visibility
err({
code: "PARTIAL_FAILURE",
message: "Import completed with row errors",
details: { accepted: 9970, rejectedCount: 30 },
});
Pick one product rule and document it. The anti-pattern is 200 with silent drops.
What Still Throws
// Unexpected - invariant broken after a successful lock
const batch = await db.batches.find(batchId);
if (!batch) {
throw new Error(`batch ${batchId} missing after atomic import claim`);
}
Unexpected handler → log with stack → 500 + generic client message.
Do not invent an ImportErrorCode for programmer mistakes; you will start swallowing bugs as “business errors.”
Testing Becomes Straightforward
import { okAsync } from "neverthrow";
it("returns CONFLICT while the claimed batch is still running", async () => {
const result = await importBatch(sample, {
...deps,
importAtomically: () => okAsync({ state: "running" as const }),
});
expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.code).toBe("CONFLICT");
}
});
No assert on thrown strings. No digging through middleware. The domain test is the contract test.
Adoption Path (Do Not Boil the Ocean)
- Define
{ code, message, details? }for one flow (imports, exports, rebuild-report). - Put
neverthrowbehind that flow’s service boundary. - Add a single HTTP mapper (+ Zod at the edge if you do not already validate).
- Teach the UI 3–5 codes.
- Only then spread to the next data path.
Rewriting every throw new Error in a legacy monolith in one PR is how good ideas die in review.
Anti-Patterns
| Anti-pattern | Why it hurts |
|---|---|
Result for every null inside a tight loop | Noise; use normal control flow locally |
| 40 error codes on day one | Nobody branches on them; codes rot |
Stack traces or SQL in details | Security + noise |
Throwing in domain and returning Result | Callers never know which style wins |
Auto-retry on CONFLICT / VALIDATION | Amplifies load; confuses operators |
any in catch then String(err) to the client | You are back to toast soup |
When neverthrow Is the Right Default
Use it when:
- Failures are product-visible and finite
- Multiple IO steps must compose without losing error types
- UI, API, and metrics need a shared code set
Skip it when:
- The function is pure leaf logic with no meaningful failure modes
- You are at the process edge (
main, worker boot) and should crash the process
Alternatives exist (ts-results, hand-rolled unions, Effect for a fuller runtime). For most fullstack TypeScript services, neverthrow + a small error-code union + one HTTP map is the sweet spot: best-practice clarity without a platform rewrite.
Closing
In data-intensive apps, failure is not an edge case - it is traffic.
Treat duplicate batches, bad rows, and warehouse timeouts as typed outcomes. Keep exceptions for the things that should wake you up. Your API gets honest, your UI gets specific, and your future self stops grepping logs for "Error: duplicate".
Building production systems end to end - more notes at mamenesia.com. Contact via the contact page.