Skip to main content

Error Handling

The Fundamental Question

Should we silently mask errors, return undefined, or throw errors?

The Modern Answer: Fail Fast, Fail Loud

// ❌ Lodash approach (silent failure) - AVOID
_.get(null, "a.b.c"); // → undefined (masks the problem)

// ✅ Modern approach (explicit failure) - PREFER
get(null, "a.b.c"); // → throw TypeError("Expected object, got null")

Pithos Rules

SituationError TypeResponsible ModuleApproachExample
Misuse / InvariantDeveloper ErrorArkhe (Foundations)Throw Error (Fail Fast)throw new TypeError()
Possible absenceExpected AbsenceAllundefinedfind(...) ?? undefined

Concrete Examples

// 1️⃣ Invalid input → THROW
const chunk = <T>(array: T[], size: number): T[][] => {
// ✅ We check values, not parameter types
// TypeScript already guarantees that array is T[] and size is number
if (size <= 0 || !Number.isInteger(size)) {
throw new RangeError("Chunk size must be a positive integer, got " + size);
}
// ... implementation
};

// ❌ NEVER check parameter types
const badChunk = <T>(array: T[], size: number): T[][] => {
if (!Array.isArray(array)) {
// ❌ TypeScript already guarantees that array is T[]
throw new TypeError("Expected array");
}
if (typeof size !== "number") {
// ❌ TypeScript already guarantees that size is number
throw new TypeError("Expected number");
}
// ...
};

// 2️⃣ Expected absent value → UNDEFINED
const find = <T>(
array: T[],
predicate: (item: T) => boolean
): T | undefined => {
for (const item of array) {
if (predicate(item)) return item;
}
return undefined; // Not found = normal case
};

// 3️⃣ Fallible operation → RESULT (via Zygos)
const parseJSON = (json: string): Result<unknown, SyntaxError> => {
try {
return ok(JSON.parse(json));
} catch (error) {
return err(error as SyntaxError);
}
};

Why This Choice?

  • Easier debugging: Silent errors cause hard-to-trace bugs
  • Fail fast: Detect problems as early as possible in the development cycle
  • TypeScript alignment: The type system reflects actual behavior
  • Modern JS ecosystem: Compatible with ?. and ?? for absent values
important

Golden rule: If the input is malformed, it's a developer error → throw.

If the value is simply absent, it's a normal case → undefined.

Return Values

Consistent Return Types

  • T | undefined: Acceptable when absence is semantically meaningful (e.g., findBest, sample, minBy return undefined when no element is found).
  • Empty collections: Return [] (empty array) when an empty collection is a valid result. Throw an error when an empty collection indicates invalid input (follow the "Fail Fast" principle).
// ✅ Good: undefined is semantically correct for "not found"
export function sample<T>(array: readonly T[]): T | undefined {
return array.length ? array[Math.floor(Math.random() * array.length)] : undefined;
}

// ✅ Good: Empty array for valid empty result
export function unionBy<T, Key>(
arrays: readonly (readonly T[])[],
iteratee: (item: T) => Key
): T[] {
if (arrays.length === 0) return []; // Valid: no arrays to union = empty result
// ...
}

// ✅ Good: Throw when an empty collection indicates invalid input
export function process<T>(array: T[]): T[] {
if (array.length === 0) {
throw new RangeError("Array must not be empty"); // Invalid: function requires at least one element
}
// ...
}

// ❌ Bad: Inconsistent return type
export function process<T>(array: T[]): T[] | undefined {
return array.length > 0 ? array.map(...) : undefined; // Should return [] if empty is valid, or throw if invalid
}

Documentation: Explicitly document return values for edge cases in TSDoc.

Boundary Validation

What is a "boundary"?

A boundary is any data source not guaranteed by TypeScript:

  • checkmark External API (fetch, WebSocket)
  • checkmark Storage (localStorage, sessionStorage, IndexedDB)
  • checkmark User input (forms, URL params)
  • checkmark JSON.parse() (returns any)
  • checkmark Third-party libs returning any or incorrect types
  • checkmark Legacy JavaScript code without reliable types

Rule: If TypeScript can't guarantee the type → Validate with Kanon

The Crucial Question

If the backend sends me a string instead of a number, who's responsible?

Validation Architecture

┌─────────────────────────────────────────────────────────┐
│ 🌍 OUTSIDE WORLD (untyped, unreliable) │
│ • Backend API │
│ • localStorage / sessionStorage │
│ • URL params / query strings │
│ • User input (forms) │
│ • WebSocket messages │
│ • Uploaded files │
└─────────────────────┬───────────────────────────────────┘


┌──────────────────────┐
│ 🛡️ VALIDATION │ ← Kanon (schema validation)
│ (Boundary) │ Zygos Result for errors
│ │
│ Responsible for: │
│ • Validating types │
│ • Parsing data │
│ • Reporting errors │
│ properly │
└────────┬─────────────┘
│ ✅ Validated and typed data

┌─────────────────────────────────────────────────────────┐
│ 🏛️ INTERNAL WORLD (typed, reliable) │
│ • Arkhe utilities │
│ • Business logic │
│ • React/Vue/Solid Components │
│ │
│ Arkhe ASSUMES data is already validated. │
│ If it's not → throw (developer error) │
└─────────────────────────────────────────────────────────┘

Concrete Example: Backend Data

import { validation } from "pithos/kanon/validation";
import { chunk } from "pithos/arkhe/array/chunk";

// 1️⃣ Validation schema (BOUNDARY)
const UserResponseSchema = validation.object({
id: validation.string(),
scores: validation.array(validation.number()), // ← MUST be number[]
});

// 2️⃣ Fetch + Validation at the boundary
async function fetchUser(userId: string) {
const response = await fetch("/api/users/" + userId);
const rawData = await response.json(); // ← Type: unknown

// Validation with Kanon
const result = UserResponseSchema.safeParse(rawData);

if (!result.success) {
// ⚠️ The backend sent invalid data
// This is an API CONTRACT problem, not an Arkhe problem
console.error("API contract violation:", result.error);
throw new Error("Invalid API response: " + result.error.message);
}

return result.data; // ← Type: { id: string; scores: number[] }
}

// 3️⃣ Safe Arkhe usage
async function processUser(userId: string) {
const user = await fetchUser(userId); // ← Validated data

// At this point, TypeScript KNOWS that user.scores is number[]
const scoreGroups = chunk(user.scores, 5); // ← 100% type-safe!

return scoreGroups;
}

Who Is Responsible for What?

Data SourceResponsibleAction
Dev misuses Arkhe APIArkhethrow TypeError
Backend sends wrong typesKanonValidation + error reporting
Invalid user inputKanonForm validation
Corrupted localStorageKanonParse + validate
Malformed WebSocket messageKanonSchema validation
caution

Arkhe does NOT validate external data.

Its role is to be a performant utility library, not a validation system.

Validation belongs at the boundary (Kanon, Zod, or custom validation).

Anti-Pattern: Validation in Arkhe

// ❌ ANTI-PATTERN: Don't do this in Arkhe
const chunk = <T>(array: T[], size: number): T[][] => {
// External data validation ← NOT ARKHE'S ROLE
if (typeof array === "string") {
return []; // Silent failure for invalid data
}
// ...
};

// ✅ CORRECT: Arkhe assumes valid data (TypeScript guarantees types)
const chunk = <T>(array: T[], size: number): T[][] => {
// Only check invalid values, not types
if (size <= 0 || !Number.isInteger(size)) {
throw new RangeError("Chunk size must be a positive integer, got " + size);
}
// ...
};

Philosophy Summary

The guiding principle behind Arkhe's error handling can be summarized as follows: TypeScript's type system is the first line of defense, and runtime checks only cover values that types cannot express (like positive integers or non-empty arrays):

┌─────────────────────────────────────────────────────────┐
│ │
│ "Arkhe trusts TypeScript types. │
│ If types are wrong at runtime, it means │
│ boundary validation wasn't done." │
│ │
│ → This is NOT Arkhe's problem. │
│ → It's the problem of the code calling Arkhe. │
│ │
└─────────────────────────────────────────────────────────┘

🔧 Coded Errors with Sphalma

Current Architecture

Sphalma provides a CodedError class that extends the native Error with a numeric code, a human-readable type string, and optional structured details. This pattern enables programmatic error matching while keeping stack traces and developer-friendly messages intact:

// sphalma/error-factory.ts
export class CodedError extends Error {
constructor(
public readonly code: number,
public readonly type: string,
public readonly details?: unknown
) {
super("[" + type + ":" + code + "]");
}
}

Hexadecimal Convention 0xMFEE

To maximize capacity and technical readability, Pithos uses a 4-digit hexadecimal format:

Format: 0x M F EE
│ │ └── Error (00-FF) → 256 errors / feature
│ └──── Feature (0-F) → 16 features / module
└────── Module (1-F) → 15 modules

Code Ranges by Module

Module (M)Feature (F)Hex RangeDescription
3 (Kanon)00x3000 - 0x30FFValidation (future)

Usage Example

import { createErrorFactory } from "pithos/sphalma/error-factory";

// Define codes (Hex)
export const AnimationErrorCodes = {
ANIMATION_ALREADY_EXISTS: 0x1000,
ANIMATION_NOT_FOUND: 0x1001,
INVALID_ANIMATION_ID: 0x1002,
} as const;

// Typed factory
export const createAnimationError =
createErrorFactory<
(typeof AnimationErrorCodes)[keyof typeof AnimationErrorCodes]
>("Animation");

// Usage
throw createAnimationError(AnimationErrorCodes.ANIMATION_NOT_FOUND, {
id: "fade-out",
});
// → Error: [Animation:101] with details.id = "fade-out"
tip

Coded Errors Advantages:

  • Debugging: The code [Animation:0x1001] immediately identifies the error
  • Logs: Easy to filter/search in logs

Related