Back to catalog

@bun-and-butter/error

A tiny foundation for strongly typed domain errors in TypeScript. Define consistent error classes with machine-readable codes, default messages, optional causes, and structured metadata. It works well for application domains that need predictable error handling, clear diagnostics, and a small, reusable error surface.

Tags
v1.0.0
Install

Install from Git

Bun & Butter packages are installed directly from the GitHub repository over SSH. This is the recommended install path for the current version. See the Bun docs on Git dependencies for the exact `bun add` behavior.

					bun add [email protected]:bun-and-butter/error.git
				
Documentation

README.md

A tiny Bun-first foundation for strongly typed domain errors in TypeScript. Define consistent error classes with machine-readable codes, default messages, optional causes, and structured metadata.

@bun-and-butter/error gives you a single base class for building application errors with:

  • machine-readable error codes
  • consistent default messages
  • optional cause support
  • structured metadata for logs and diagnostics
  • a discriminator field for easier narrowing

Quick Start

import { BaseError } from "@bun-and-butter/error";

enum UserErrorCode {
    InvalidID = "USER-1000",
    InvalidUsername = "USER-1001",
}

class UserError extends BaseError<UserErrorCode> {
    readonly type = "UserError" as const;

    static readonly messages = {
        [UserErrorCode.InvalidID]: "The provided user identifier is not a valid UUID v7",
        [UserErrorCode.InvalidUsername]: "The username must be between 3 and 20 characters long",
    } as const satisfies Record<UserErrorCode, string>;

    static invalidID(cause?: unknown): UserError {
        return new UserError(UserErrorCode.InvalidID, { cause });
    }

    static invalidUsername(username: string): UserError {
        return new UserError(UserErrorCode.InvalidUsername, {
            meta: { username },
        });
    }
}

Why Use It

Plain Error objects are often enough until you need one of these:

  • distinguish failures by stable codes instead of parsing strings
  • preserve low-level exceptions with cause
  • attach structured context like IDs or field names
  • model domain errors with a type-safe API

BaseError keeps those concerns small and consistent.

Works Well With “TypeScript Result”

If you prefer returning typed results instead of throwing immediately, @bun-and-butter/error pairs nicely with typescript-result.

That combination works especially well when you want:

  • strongly typed domain errors
  • explicit success and failure return values
  • predictable error handling without relying only on exceptions

Examples

The repository currently includes two examples:

  • examples/demo.ts shows the “throwing” style with a typed domain error, static factory methods, and narrowing via instanceof
  • examples/with_typescript_result.ts shows a result-based alternative using typescript-result, Result.try(...), and explicit success and failure return values

Design Notes

  • BaseError restores the prototype chain so instanceof checks work reliably.
  • The type discriminator helps keep concrete subclasses distinct during TypeScript narrowing.
  • The generic Code parameter ensures only valid codes can be assigned to an error instance.

For each domain, define:

  1. an enum of stable error codes
  2. one error class extending BaseError
  3. a messages map covering every code
  4. optional static factories like invalidID() for common failure cases

This gives you readable call sites and a consistent error surface across the codebase.

Usage Examples

Examples

examples/demo.ts
import { BaseError } from "../src/error";

// This example shows the "throwing" style of using `BaseError`.
//
// The idea is:
// - model domain failures as a dedicated error class
// - throw typed errors at validation boundaries
// - narrow back to the concrete error type with `instanceof`
//
// This keeps call sites readable while still giving us structured data like
// error codes, default messages, optional causes, and diagnostic metadata.

/**
 * Error codes used by the demo domain.
 */
export enum DemoErrorCode {
    /** The provided identifier is not a valid UUID v7. */
    InvalidID = "UE-1000",
    /** A username failed the package's demo validation rules. */
    InvalidUsername = "UE-1001",
}

/**
 * Discriminator literal used for narrowing this custom error class.
 */
export const DemoErrorType = "DemoError" as const;

/**
 * Example domain error built on top of {@link BaseError}.
 */
export class DemoError extends BaseError<DemoErrorCode> {
    /** Literal discriminator for this concrete error type. */
    readonly type = DemoErrorType;

    /** Default messages keyed by machine-readable error code. */
    static readonly messages = {
        [DemoErrorCode.InvalidID]: "The provided user identifier is not a valid UUID v7",
        [DemoErrorCode.InvalidUsername]: "The username must be between 3 and 20 characters long",
    } as const satisfies Record<DemoErrorCode, string>;

    /**
     * Creates an error for invalid user identifiers.
     */
    static invalidID(cause?: unknown): DemoError {
        return new DemoError(DemoErrorCode.InvalidID, { cause });
    }

    /**
     * Creates an error for invalid usernames.
     */
    static invalidUsername(username: string): DemoError {
        return new DemoError(DemoErrorCode.InvalidUsername, {
            meta: { username },
        });
    }
}

/**
 * Minimal example function that validates demo input and returns a typed error.
 */
export function parseDemoUser(input: { id: string; username: string }): DemoUser {
    // Validation errors are translated into typed domain errors.
    // Callers can catch `DemoError` and inspect `code`, `message`, or `meta`.
    if (!isUuidV7(input.id)) {
        throw DemoError.invalidID();
    }

    // The factory method keeps the call site small and stores the invalid value
    // in `meta`, which is useful for logs or debugging.
    if (input.username.length < 3 || input.username.length > 20) {
        throw DemoError.invalidUsername(input.username);
    }

    // If all checks pass, we return the validated domain object.
    return {
        id: input.id,
        username: input.username,
    };
}

/**
 * Example DTO returned by {@link parseDemoUser}.
 */
export interface DemoUser {
    id: string;
    username: string;
}

function isUuidV7(value: string): boolean {
    // This helper keeps the example focused on error handling rather than
    // inlining the UUID check into the main parsing function.
    return /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

try {
    // This input fails validation on purpose so the example demonstrates
    // what a typed domain error looks like at runtime.
    parseDemoUser({
        id: "not-a-uuid",
        username: "ab",
    });
} catch (error) {
    // `instanceof` narrows the caught value back to our concrete error class.
    // From here on, TypeScript knows about `type`, `code`, `message`, and `meta`.
    if (error instanceof DemoError) {
        console.error(error.type, error.code, error.message, error.meta);
    }
}
examples/with_typescript_result.ts
import { Result } from "typescript-result";
import { BaseError } from "../src/error";

// This example shows how `BaseError` from this package can
// be combined with `typescript-result`.
//
// The idea is:
// - we do not want to pass around loose `Error` objects
// - instead, we model domain failures as their own error class
// - and we return an explicit `Result` for both success and failure
//
// That makes it immediately visible to callers:
// - which success value comes back (`User`)
// - which error type can occur (`UserParseError`)
//
// Example inspired by the TypeScript Result docs:
// https://www.typescript-result.dev/

/**
 * Stable, machine-readable error codes for every failure case in this example.
 *
 * These codes are intentionally separate from the human-readable message:
 * programs can branch on the code, while logs and UIs can show the message.
 */
enum UserParseErrorCode {
	InvalidJSON = "USER-1000",
	InvalidPayload = "USER-1001",
	MissingUsername = "USER-1002",
	UsernameTooShort = "USER-1003",
}

/**
 * Concrete domain error for the user parsing flow.
 *
 * `BaseError` already provides:
 * - `message`
 * - `code`
 * - optional `meta`
 * - optional `cause`
 *
 * This subclass only needs to define its discriminator and default messages.
 */
class UserParseError extends BaseError<UserParseErrorCode> {
	/** Literal discriminator used for narrowing and matching this error class. */
	readonly type = "UserParseError" as const;

	/**
	 * Default message for each error code.
	 *
	 * `satisfies Record<...>` makes sure we do not forget a code
	 * and do not accidentally add an invalid key.
	 */
	static readonly messages = {
		[UserParseErrorCode.InvalidJSON]:
            "The provided string is not valid JSON",
		[UserParseErrorCode.InvalidPayload]:
			"The parsed value is not a valid user payload",
		[UserParseErrorCode.MissingUsername]:
			"The payload does not contain a username",
		[UserParseErrorCode.UsernameTooShort]:
			"The username must be at least 3 characters long",
	} as const satisfies Record<UserParseErrorCode, string>;

	/**
	 * Creates an error for invalid JSON input.
	 *
	 * `cause` is especially useful when we want to translate a low-level error
	 * from `JSON.parse` into a domain error without losing the original failure.
	 */
	static invalidJSON(cause?: unknown): UserParseError {
		return new UserParseError(UserParseErrorCode.InvalidJSON, { cause });
	}

	/**
	 * Creates an error for values that are not valid user payload objects.
	 *
	 * The original input is stored in `meta` so it can be logged or inspected.
	 */
	static invalidPayload(input: unknown): UserParseError {
		return new UserParseError(UserParseErrorCode.InvalidPayload, {
			meta: { input },
		});
	}

	/**
	 * Creates an error for payloads that do not provide a usable username.
	 *
	 * The original input is preserved in `meta` for diagnostics.
	 */
	static missingUsername(input: unknown): UserParseError {
		return new UserParseError(UserParseErrorCode.MissingUsername, {
			meta: { input },
		});
	}

	/**
	 * Creates an error for usernames that violate the minimum length rule.
	 *
	 * The actual invalid username is stored in `meta`.
	 */
	static usernameTooShort(username: string): UserParseError {
		return new UserParseError(UserParseErrorCode.UsernameTooShort, {
			meta: { username },
		});
	}
}

/**
 * Successful output of the parser.
 *
 * If parsing and validation succeed, this is the value stored
 * in the `Ok` branch of the `Result`.
 */
type User = {
	username: string;
};

/**
 * Validates already parsed JSON against the domain rules for `User`.
 *
 * The return type `Result<User, UserParseError>` makes both branches explicit:
 * success returns a `User`, failure returns a `UserParseError`.
 */
function validateUser(value: unknown): Result<User, UserParseError> {
	// First, make sure we actually received an object.
	if (typeof value !== "object" || value === null) {
		return Result.error(UserParseError.invalidPayload(value));
	}

	// Then check whether the `username` field exists at all.
	if (!("username" in value)) {
		return Result.error(UserParseError.missingUsername(value));
	}

	// At this point TypeScript knows that `username` exists on the object.
	// But we still need to validate the runtime type of that field.
	const { username } = value;
	if (typeof username !== "string") {
		// In this example, we treat "not a string" the same as
		// "username missing". If needed, this could also become
		// its own dedicated error code such as "wrong type".
		return Result.error(UserParseError.missingUsername(value));
	}

	// Domain rule: usernames must be at least 3 characters long.
	if (username.length < 3) {
		return Result.error(UserParseError.usernameTooShort(username));
	}

	// If every check passes, we can build the success value.
	return Result.ok({ username });
}

/**
 * Parses a JSON string and validates that it contains a valid user payload.
 *
 * This version keeps the control flow explicit:
 * first we convert `JSON.parse` into a `Result`,
 * then we either return the parse error or validate the parsed value.
 */
function parseUserDocument(input: string): Result<User, UserParseError> {
	const parsedJson = Result.try(
		// `Result.try(...)` converts code that might throw exceptions
		// into a `Result`.
		() => JSON.parse(input) as unknown,
		// The second argument maps the thrown error
		// into our own domain error type.
		(error) => UserParseError.invalidJSON(error),
	);

	// If parsing failed, we return that error result directly.
	if (!parsedJson.ok) {
		return parsedJson;
	}

	// Otherwise we continue with domain validation of the parsed value.
	return validateUser(parsedJson.value);
}

/**
 * Runs the example flow and prints either the validated user
 * or a formatted error with its structured metadata.
 */
function printResult(input: string) {
	// The full parsing and validation flow returns exactly one `Result`.
	const result = parseUserDocument(input);

	// `result.ok` is the simplest branching point:
	// - `true`  => we may read `result.value`
	// - `false` => we may read `result.error`
	if (!result.ok) {
		const message = result
			.match()
			// `match()` allows a pattern-matching style of handling.
			// Here we say: if the error is a `UserParseError`,
			// format it using its code and message.
			.when(UserParseError, (error) => {
				return `[${error.code}] ${error.message}`;
			})
			.run();

		// Besides the readable message, we also print `meta`.
		// That is where our structured context lives,
		// such as the invalid input or the too-short username.
		console.error("error:", message);
		console.error("meta:", result.error.meta ?? null);
		return;
	}

	// On success, the validated user is available in `result.value`.
	console.log("user:", result.value);
}

// A few example calls for the demo:
// - valid user
// - username too short
// - field missing
// - invalid JSON
printResult('{"username":"butter"}');
printResult('{"username":"ab"}');
printResult('{"foo":"bar"}');
printResult("not json at all");