diff --git a/.size-limit.json b/.size-limit.json index 47600c3..18ab8c5 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -41,6 +41,12 @@ "limit": "195 B", "brotli": true }, + { + "name": "@peerigon/typescript-toolkit/assert-never", + "path": "dist/assert-never/assert-never.js", + "limit": "200 B", + "brotli": true + }, { "name": "@peerigon/typescript-toolkit/dedupe", "path": "dist/dedupe/dedupe.js", diff --git a/README.md b/README.md index d2fd77b..63c0dee 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ import { assert } from "@peerigon/typescript-toolkit/assert"; | [`api/rate-limit`](./src/api/rate-limit/README.md) | Rate-limited `fetch` for `defineApi` (pacing + Retry-After) | [→](./src/api/rate-limit/README.md) | | [`api/result`](./src/api/result/README.md) | `defineApiResult` — like `defineApi`, but returns `Result.Sync` | [→](./src/api/result/README.md) | | [`assert`](./src/assert/README.md) | Assert a value is not `null` or `undefined`, with TypeScript narrowing | [→](./src/assert/README.md) | +| [`assert-never`](./src/assert-never/README.md) | Assert a code path is unreachable, for exhaustive `switch`/`case` statements | [→](./src/assert-never/README.md) | | [`concurrency/once`](./src/concurrency/once/README.md) | Run an async function at most once (single-flight + cache) | [→](./src/concurrency/once/README.md) | | [`concurrency/once/result`](./src/concurrency/once/result/README.md) | `once` with a synchronous `Result` snapshot | [→](./src/concurrency/once/result/README.md) | | [`concurrency/exactlyOnce`](./src/concurrency/exactlyOnce/README.md) | Invoke an async function exactly once; second call throws | [→](./src/concurrency/exactlyOnce/README.md) | diff --git a/jsr.json b/jsr.json index 5fd3d75..760e7cd 100644 --- a/jsr.json +++ b/jsr.json @@ -7,6 +7,7 @@ "./api/rate-limit": "./src/api/rate-limit/rate-limit.ts", "./api/result": "./src/api/result/result.ts", "./assert": "./src/assert/assert.ts", + "./assert-never": "./src/assert-never/assert-never.ts", "./concurrency/exactlyOnce": "./src/concurrency/exactlyOnce/exactlyOnce.ts", "./concurrency/exactlyOnce/result": "./src/concurrency/exactlyOnce/result/result.ts", "./concurrency/mutex": "./src/concurrency/mutex/mutex.ts", diff --git a/package.json b/package.json index 8b888ce..505ea83 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "./api/rate-limit": "./dist/api/rate-limit/rate-limit.js", "./api/result": "./dist/api/result/result.js", "./assert": "./dist/assert/assert.js", + "./assert-never": "./dist/assert-never/assert-never.js", "./concurrency/exactlyOnce": "./dist/concurrency/exactlyOnce/exactlyOnce.js", "./concurrency/exactlyOnce/result": "./dist/concurrency/exactlyOnce/result/result.js", "./concurrency/mutex": "./dist/concurrency/mutex/mutex.js", diff --git a/src/assert-never/README.md b/src/assert-never/README.md new file mode 100644 index 0000000..16cd8d7 --- /dev/null +++ b/src/assert-never/README.md @@ -0,0 +1,59 @@ +## `assertNever` + +- 📦 Below 200 Bytes minified + compressed (brotli) +- ✅ Zero dependencies + +Assert that a code path is unreachable, typically the `default` case of a `switch`/`case` statement over a union type. + +`assertNever` only accepts a value whose type has already been narrowed to `never`. If you handle every case of a union, TypeScript narrows the value in the `default` branch to `never` and the call compiles. If the union later gains a new member, TypeScript reports a compile-time error at the `assertNever` call site instead of silently falling through. At runtime, `assertNever` throws, guarding against values that slip through despite the type system (e.g. from `JSON.parse` or an external API). + +### Basic usage + +```ts +import { assertNever } from "@peerigon/typescript-toolkit/assert-never"; + +type Direction = "down" | "up"; + +const describeDirection = (direction: Direction): string => { + switch (direction) { + case "up": + return "going up"; + case "down": + return "going down"; + default: + // TypeScript error here if a case for "Direction" is missing + return assertNever(direction); + } +}; +``` + +### With custom error message + +```ts +default: + return assertNever(direction, "Unhandled direction"); + +// Custom error messages just for the development build. Production builds will remove the message. In that case, a generic default error message is used. +default: + return assertNever( + direction, + import.meta.env.DEV && `Unhandled direction: ${direction}`, + ); +``` + +### API Reference + +#### `assertNever(value, errorMessage?)` + +Asserts that `value` is of type `never` and throws at runtime. + +```ts +assertNever(value: never, errorMessage?: ErrorMessage): never +``` + +| Parameter | Type | Description | +| -------------- | ------------------------- | --------------------------------------------------------------------------------------- | +| `value` | `never` | Value that should be unreachable, i.e. all union members have already been handled | +| `errorMessage` | `ErrorMessage` (optional) | Custom message: `string`, `false`, or a lazy function. Default: `"Unexpected value: …"` | + +**Throws:** `TypeError` unconditionally, since reaching this function is always a bug or an unexpected runtime value. diff --git a/src/assert-never/assert-never.test.ts b/src/assert-never/assert-never.test.ts new file mode 100644 index 0000000..a3d82f5 --- /dev/null +++ b/src/assert-never/assert-never.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { assertNever } from "./assert-never.js"; + +describe("assertNever()", () => { + it("throws for any given value", () => { + expect(() => + assertNever("unexpected" as never), + ).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Unexpected value: "unexpected"]`, + ); + }); + + it("throws for objects", () => { + expect(() => + assertNever({ type: "unknown" } as never), + ).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Unexpected value: {"type":"unknown"}]`, + ); + }); + + it("uses custom message when provided", () => { + expect(() => + assertNever("unexpected" as never, "Custom assertion error"), + ).toThrow("Custom assertion error"); + }); + + it("calls the function message when provided", () => { + const messageFn = () => "Dynamic error message"; + expect(() => assertNever("unexpected" as never, messageFn)).toThrow( + "Dynamic error message", + ); + }); + + describe("exhaustiveness checking", () => { + type Direction = "down" | "up"; + + const describeDirection = (direction: Direction): string => { + switch (direction) { + case "up": { + return "going up"; + } + case "down": { + return "going down"; + } + default: { + return assertNever(direction); + } + } + }; + + it("handles every case without reaching the default branch", () => { + expect(describeDirection("up")).toBe("going up"); + expect(describeDirection("down")).toBe("going down"); + }); + + it("only accepts a value of type never", () => { + expectTypeOf(assertNever).parameter(0).toEqualTypeOf(); + }); + }); +}); diff --git a/src/assert-never/assert-never.ts b/src/assert-never/assert-never.ts new file mode 100644 index 0000000..7b0d114 --- /dev/null +++ b/src/assert-never/assert-never.ts @@ -0,0 +1,27 @@ +import { getErrorMessage, type ErrorMessage } from "../lib/error-message.ts"; +import { stringify } from "../lib/string.ts"; + +/** + * Asserts that a code path is unreachable, typically the `default` case of a + * `switch`/`case` statement over a union type. + * + * TypeScript only accepts `value` if its type has already been narrowed to + * `never`, i.e. all members of the union have been handled in the preceding + * cases. This turns an unhandled case into a compile-time error as soon as + * the union gains a new member, while still throwing at runtime if an + * unexpected value slips through. + * + * @param value - The value that should be of type `never`. + * @param errorMessage - The error message to throw. + */ +export const assertNever = ( + value: never, + errorMessage?: ErrorMessage, +): never => { + throw new TypeError( + getErrorMessage( + errorMessage, + () => `Unexpected value: ${stringify(value)}`, + ), + ); +};