diff --git a/src/lib/invariant.ts b/src/lib/invariant.ts new file mode 100644 index 0000000..a49ebd6 --- /dev/null +++ b/src/lib/invariant.ts @@ -0,0 +1,113 @@ +/** + * Invariant assertion helpers for runtime validation. + * + * These utilities provide type-safe assertions that narrow types and + * throw descriptive errors when invariants are violated. + */ + +/** + * Custom error class for invariant violations. + * Provides a distinct error type that can be caught and identified. + */ +export class InvariantError extends Error { + constructor(message: string) { + super(message); + this.name = "InvariantError"; + + // Maintains proper stack trace in V8 environments (Node, Chrome) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, InvariantError); + } + } +} + +/** + * Message can be a string or a function that returns a string. + * Using a function allows for lazy evaluation of expensive messages. + */ +export type InvariantMessage = string | (() => string); + +/** + * Assert that a condition is truthy. Throws InvariantError if false. + * + * This function acts as a type guard - TypeScript will narrow the type + * after the assertion. Useful for null/undefined checks. + * + * @param condition - The condition to check (must be truthy) + * @param message - Error message or function returning message + * @throws InvariantError if condition is falsy + * + * @example + * ```ts + * const user: User | null = getUser(); + * invariant(user !== null, "User must be logged in"); + * // TypeScript now knows user is User, not null + * console.log(user.name); + * ``` + * + * @example + * ```ts + * // Lazy message evaluation for performance + * invariant(isValid, () => `Invalid state: ${JSON.stringify(state)}`); + * ``` + */ +export function invariant( + condition: unknown, + message: InvariantMessage +): asserts condition { + if (!condition) { + const errorMessage = typeof message === "function" ? message() : message; + throw new InvariantError(errorMessage); + } +} + +/** + * Assert that a value should never be reached (exhaustive type checking). + * + * Use this in the default case of switch statements to ensure all + * union members are handled. TypeScript will error at compile time + * if a case is missing. + * + * @param value - The value that should be of type `never` + * @returns never - This function always throws + * @throws InvariantError with a description of the unexpected value + * + * @example + * ```ts + * type Status = "pending" | "complete" | "failed"; + * + * function handleStatus(status: Status): string { + * switch (status) { + * case "pending": return "Waiting..."; + * case "complete": return "Done!"; + * case "failed": return "Error occurred"; + * default: + * // If a new status is added, TypeScript will error here + * return assertNever(status); + * } + * } + * ``` + */ +export function assertNever(value: never): never { + const description = formatValue(value); + throw new InvariantError(`Unexpected value: ${description}`); +} + +/** + * Format a value for display in error messages. + * Handles objects, arrays, and primitives. + */ +function formatValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + return String(value); +} diff --git a/tests/lib/invariant.test.ts b/tests/lib/invariant.test.ts new file mode 100644 index 0000000..22f0b1f --- /dev/null +++ b/tests/lib/invariant.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { + invariant, + assertNever, + InvariantError, +} from "@/lib/invariant"; + +describe("InvariantError", () => { + it("is an instance of Error", () => { + const error = new InvariantError("test message"); + expect(error).toBeInstanceOf(Error); + }); + + it("has the correct name", () => { + const error = new InvariantError("test message"); + expect(error.name).toBe("InvariantError"); + }); + + it("preserves the message", () => { + const error = new InvariantError("something went wrong"); + expect(error.message).toBe("something went wrong"); + }); + + it("has a stack trace", () => { + const error = new InvariantError("test"); + expect(error.stack).toBeDefined(); + }); +}); + +describe("invariant", () => { + it("does not throw when condition is true", () => { + expect(() => invariant(true, "should not throw")).not.toThrow(); + }); + + it("does not throw for truthy values", () => { + expect(() => invariant(1, "truthy number")).not.toThrow(); + expect(() => invariant("non-empty", "truthy string")).not.toThrow(); + expect(() => invariant({}, "empty object is truthy")).not.toThrow(); + expect(() => invariant([], "empty array is truthy")).not.toThrow(); + }); + + it("throws InvariantError when condition is false", () => { + expect(() => invariant(false, "invariant violated")).toThrow( + InvariantError + ); + }); + + it("throws InvariantError for falsy values", () => { + expect(() => invariant(null, "null check")).toThrow(InvariantError); + expect(() => invariant(undefined, "undefined check")).toThrow( + InvariantError + ); + expect(() => invariant(0, "zero check")).toThrow(InvariantError); + expect(() => invariant("", "empty string check")).toThrow(InvariantError); + }); + + it("includes the message in the error", () => { + try { + invariant(false, "Expected value to be defined"); + } catch (e) { + expect(e).toBeInstanceOf(InvariantError); + expect((e as InvariantError).message).toBe("Expected value to be defined"); + } + }); + + it("works as a type guard narrowing null/undefined", () => { + const maybeValue: string | null = "hello"; + invariant(maybeValue !== null, "value should not be null"); + // TypeScript should now know maybeValue is string + const length: number = maybeValue.length; + expect(length).toBe(5); + }); + + it("works with a lazy message function", () => { + const expensiveMessage = () => `Computed at ${Date.now()}`; + expect(() => invariant(false, expensiveMessage)).toThrow(InvariantError); + }); + + it("only evaluates lazy message when condition fails", () => { + let called = false; + const lazyMessage = () => { + called = true; + return "computed message"; + }; + + invariant(true, lazyMessage); + expect(called).toBe(false); + + try { + invariant(false, lazyMessage); + } catch { + // expected + } + expect(called).toBe(true); + }); +}); + +describe("assertNever", () => { + it("throws InvariantError when called", () => { + // We need to force TypeScript to let us call this + const invalid = "unexpected" as never; + expect(() => assertNever(invalid)).toThrow(InvariantError); + }); + + it("includes the unexpected value in the error message", () => { + const invalid = "unexpected_value" as never; + try { + assertNever(invalid); + } catch (e) { + expect(e).toBeInstanceOf(InvariantError); + expect((e as InvariantError).message).toContain("unexpected_value"); + } + }); + + it("handles object values in error message", () => { + const invalid = { type: "unknown" } as never; + try { + assertNever(invalid); + } catch (e) { + expect(e).toBeInstanceOf(InvariantError); + expect((e as InvariantError).message).toContain("unknown"); + } + }); + + it("is useful for exhaustive switch statements", () => { + type Status = "pending" | "complete"; + + function handleStatus(status: Status): string { + switch (status) { + case "pending": + return "waiting"; + case "complete": + return "done"; + default: + // If we add a new status, TypeScript will error here + return assertNever(status); + } + } + + expect(handleStatus("pending")).toBe("waiting"); + expect(handleStatus("complete")).toBe("done"); + }); +});