Skip to content
← Back to blog

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)

KindExamplesModel as
Expectedduplicate idempotency key, validation, not found, conflict, forbidden, upstream timeoutResult / ResultAsync
Unexpectednull where the schema guarantees a row after a txn, programmer bug, corrupted statethrow → 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 by eventId.
Fail closed on structurally invalid rows (or report partial failure when product policy allows).
Refuse to start if the same batchId is 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_TIMEOUT auto-retries; CONFLICT does 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:

  1. Result for pure checks, ResultAsync for IO - one style at the service boundary.
  2. Injected deps - domain stays unit-testable; adapters own SQL/HTTP.
  3. Idempotency is a branch - duplicates increment a counter; they do not throw.
  4. Validation carries row indexes - UI can highlight offenders.
  5. andThen short-circuits - first err stops the chain; no nested try/catch ladders.

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)

  1. Define { code, message, details? } for one flow (imports, exports, rebuild-report).
  2. Put neverthrow behind that flow’s service boundary.
  3. Add a single HTTP mapper (+ Zod at the edge if you do not already validate).
  4. Teach the UI 3–5 codes.
  5. 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-patternWhy it hurts
Result for every null inside a tight loopNoise; use normal control flow locally
40 error codes on day oneNobody branches on them; codes rot
Stack traces or SQL in detailsSecurity + noise
Throwing in domain and returning ResultCallers never know which style wins
Auto-retry on CONFLICT / VALIDATIONAmplifies load; confuses operators
any in catch then String(err) to the clientYou 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.

Related posts