05 Errors-as-values thinking 6 chapters

Theme 07 · Effect / Rust / OCaml-Style Design

Errors-as-values thinking

Explanation

Errors-as-values thinking

Plain Human Explanation

Errors-as-values means expected failures are returned like normal outcomes instead of thrown as surprises.

Rust’s Result type makes this habit hard to ignore: success and failure are both part of the function’s shape. In TypeScript, we can use a small union like { ok: true, value } | { ok: false, error }.

This is useful for product failures users can recover from: invalid input, duplicate records, missing permissions, expired links, declined cards, or upload limits. The caller must choose a user message, retry path, or support path.

Technical Explanation

Use returned error values for expected domain failures. Keep exceptions for programmer mistakes, broken infrastructure, or conditions the immediate caller cannot reasonably handle.

The benefit is not just fewer try/catch blocks. The benefit is that the function signature tells callers which failures are part of normal product behavior.

Why It Matters

  • User impact: expected failures get clear messages and recovery paths.
  • Product behavior: callers cannot pretend a recoverable failure does not exist.
  • Risk: thrown errors skip normal UI, logging, retry, or support handling.
  • Decision point: use this when the caller can make a meaningful product choice after the failure.

The Core Move

Return a small result union for expected failures, then make the caller handle both success and failure deliberately.

Small Example

Errors-as-values thinking: Small Example

Bad TypeScript Example

export function parseSeatCount(input: string) {
  const seats = Number(input);
  if (!Number.isInteger(seats)) {
    throw new Error("Invalid seat count");
  }

  return seats;
}
export function parseSeatCount(
  input: string,
) {
  const seats = Number(input);

  if (!Number.isInteger(seats)) {
    throw new Error("Invalid seat count");
  }

  return seats;
}

Good TypeScript Example

type SeatCountResult =
  | { ok: true; seats: number }
  | { ok: false; error: "not-a-number" | "too-small" };

export function parseSeatCount(input: string): SeatCountResult {
  const seats = Number(input);

  if (!Number.isInteger(seats)) {
    return { ok: false, error: "not-a-number" };
  }

  if (seats < 1) {
    return { ok: false, error: "too-small" };
  }

  return { ok: true, seats };
}
type SeatCountResult =
  | {
      ok: true;
      seats: number;
    }
  | {
      ok: false;
      error: "not-a-number" | "too-small";
    };

export function parseSeatCount(
  input: string,
): SeatCountResult {
  const seats = Number(input);

  if (!Number.isInteger(seats)) {
    return {
      ok: false,
      error: "not-a-number",
    };
  }

  if (seats < 1) {
    return {
      ok: false,
      error: "too-small",
    };
  }

  return {
    ok: true,
    seats,
  };
}

What Changed

  • The bad version throws for input a user can correct.
  • The good version returns the exact validation failure.
  • A caller now has to decide which message or recovery path to show.

Realistic Example

Errors-as-values thinking: Realistic Example

This example uses password reset links. Expired and already-used links are expected product outcomes, so callers should handle them directly.

Bad TypeScript Example

type ResetToken = {
  userId: string;
  expiresAt: Date;
  usedAt?: Date;
};

export function verifyResetToken(token: ResetToken | null, now: Date) {
  if (!token) throw new Error("Token not found");
  if (token.usedAt) throw new Error("Token already used");
  if (token.expiresAt <= now) throw new Error("Token expired");

  return token.userId;
}
type ResetToken = {
  userId: string;
  expiresAt: Date;
  usedAt?: Date;
};

export function verifyResetToken(
  token: ResetToken | null,
  now: Date,
) {
  if (!token)
    throw new Error("Token not found");

  if (token.usedAt)
    throw new Error("Token already used");

  if (token.expiresAt <= now)
    throw new Error("Token expired");

  return token.userId;
}

Good TypeScript Example

type ResetToken = {
  userId: string;
  expiresAt: Date;
  usedAt?: Date;
};

type ResetTokenResult =
  | { ok: true; userId: string }
  | { ok: false; error: "not-found" | "already-used" | "expired" };

export function verifyResetToken(token: ResetToken | null, now: Date): ResetTokenResult {
  if (!token) return { ok: false, error: "not-found" };
  if (token.usedAt) return { ok: false, error: "already-used" };
  if (token.expiresAt <= now) return { ok: false, error: "expired" };

  return { ok: true, userId: token.userId };
}

export function resetTokenMessage(result: ResetTokenResult) {
  if (result.ok) return "Choose a new password.";
  if (result.error === "expired") return "This reset link expired. Request a new one.";
  if (result.error === "already-used") return "This reset link was already used.";
  return "We could not find that reset link.";
}
type ResetToken = {
  userId: string;
  expiresAt: Date;
  usedAt?: Date;
};

type ResetTokenResult =
  | {
      ok: true;
      userId: string;
    }
  | {
      ok: false;
      error:
        | "not-found"
        | "already-used"
        | "expired";
    };

export function verifyResetToken(
  token: ResetToken | null,
  now: Date,
): ResetTokenResult {
  if (!token)
    return {
      ok: false,
      error: "not-found",
    };

  if (token.usedAt)
    return {
      ok: false,
      error: "already-used",
    };

  if (token.expiresAt <= now)
    return {
      ok: false,
      error: "expired",
    };

  return {
    ok: true,
    userId: token.userId,
  };
}

export function resetTokenMessage(
  result: ResetTokenResult,
) {
  if (result.ok)
    return "Choose a new password.";

  if (result.error === "expired")
    return "This reset link expired. Request a new one.";

  if (result.error === "already-used")
    return "This reset link was already used.";

  return "We could not find that reset link.";
}

What Changed

  • The bad version turns normal reset-link outcomes into thrown errors.
  • The good version makes each expected failure part of the function’s return type.
  • The caller can show product-specific recovery copy without parsing exception text.

System Example

Errors-as-values thinking: System Example

At system scale, returned error values keep expected product failures on the same path as success: logged, translated, tested, and shown to the user deliberately.

Larger System-Level Bad TypeScript Example

type Upload = {
  userId: string;
  filename: string;
  sizeBytes: number;
};

export async function startUpload(upload: Upload, ports: any) {
  const user = await ports.users.find(upload.userId);
  if (!user) throw new Error("User not found");
  if (upload.sizeBytes > user.uploadLimitBytes) throw new Error("File too large");

  const record = await ports.uploads.create(upload);
  await ports.audit.write("upload.started", record);
  return record;
}
type Upload = {
  userId: string;
  filename: string;
  sizeBytes: number;
};

export async function startUpload(
  upload: Upload,
  ports: any,
) {
  const user = await ports.users.find(
    upload.userId,
  );

  if (!user)
    throw new Error("User not found");

  if (
    upload.sizeBytes > user.uploadLimitBytes
  )
    throw new Error("File too large");

  const record =
    await ports.uploads.create(upload);

  await ports.audit.write(
    "upload.started",
    record,
  );

  return record;
}

Larger System-Level Good TypeScript Example

type Upload = {
  userId: string;
  filename: string;
  sizeBytes: number;
};

type User = {
  id: string;
  uploadLimitBytes: number;
};

type UploadRecord = {
  id: string;
  filename: string;
};

type StartUploadResult = { ok: true; upload: UploadRecord } | { ok: false; error: "user-not-found" | "file-too-large"; limitBytes?: number };

type UploadPorts = {
  users: {
    find(userId: string): Promise<User | null>;
  };
  uploads: {
    create(upload: Upload): Promise<UploadRecord>;
  };
  audit: {
    write(event: string, fields: Record<string, string>): Promise<void>;
  };
};

export async function startUpload(upload: Upload, ports: UploadPorts): Promise<StartUploadResult> {
  const user = await ports.users.find(upload.userId);
  if (!user) return { ok: false, error: "user-not-found" };

  if (upload.sizeBytes > user.uploadLimitBytes) {
    return { ok: false, error: "file-too-large", limitBytes: user.uploadLimitBytes };
  }

  const record = await ports.uploads.create(upload);
  await ports.audit.write("upload.started", { uploadId: record.id });

  return { ok: true, upload: record };
}
type Upload = {
  userId: string;
  filename: string;
  sizeBytes: number;
};

type User = {
  id: string;
  uploadLimitBytes: number;
};

type UploadRecord = {
  id: string;
  filename: string;
};

type StartUploadResult =
  | {
      ok: true;
      upload: UploadRecord;
    }
  | {
      ok: false;
      error:
        | "user-not-found"
        | "file-too-large";
      limitBytes?: number;
    };

type UploadPorts = {
  users: {
    find(
      userId: string,
    ): Promise<User | null>;
  };
  uploads: {
    create(
      upload: Upload,
    ): Promise<UploadRecord>;
  };
  audit: {
    write(
      event: string,
      fields: Record<string, string>,
    ): Promise<void>;
  };
};

export async function startUpload(
  upload: Upload,
  ports: UploadPorts,
): Promise<StartUploadResult> {
  const user = await ports.users.find(
    upload.userId,
  );

  if (!user)
    return {
      ok: false,
      error: "user-not-found",
    };

  if (
    upload.sizeBytes > user.uploadLimitBytes
  ) {
    return {
      ok: false,
      error: "file-too-large",
      limitBytes: user.uploadLimitBytes,
    };
  }

  const record =
    await ports.uploads.create(upload);

  await ports.audit.write(
    "upload.started",
    {
      uploadId: record.id,
    },
  );

  return {
    ok: true,
    upload: record,
  };
}

What Changed

  • The bad version throws for expected upload failures that the UI can explain.
  • The good version returns typed failures with the data needed for recovery copy.
  • Infrastructure failures can still throw, but user-correctable outcomes stay on the product path.

When To Use It

Errors-as-values thinking: When To Use It

Use This When

  • The failure is expected and the caller can show a useful message, retry, redirect, or support path.
  • The product needs to distinguish several failure reasons.
  • Tests should prove both success and recoverable failure behavior without catching exception text.

Avoid This When

  • The failure is a programmer bug, such as violating an internal invariant.
  • The dependency is down and the immediate caller cannot recover locally.
  • The result type would be ignored everywhere and hide a serious operational problem.

Tradeoffs

Errors-as-values make expected failures visible, but they add branching at call sites. Use them where that branching represents a real product decision.

  • Tagged unions
  • Typed failure modes
  • Structured errors

Practice Prompt

Errors-as-values thinking: Practice Prompt

Beginner Exercise

Find one throw new Error used for invalid user input, missing permission, expired link, duplicate record, or quota limit.

Intermediate Exercise

Replace that throw with a result union. Update one caller to handle each failure with a specific product response.

Stretch Exercise

Draw a boundary between expected product failures and infrastructure failures in one workflow. Return values for the former and let the latter use the existing incident path.

Reflection Question

Can the caller make a meaningful user-facing decision from this error, or is this really an operational failure?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.