Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions src/assert-never/README.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions src/assert-never/assert-never.test.ts
Original file line number Diff line number Diff line change
@@ -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<never>();
});
});
});
27 changes: 27 additions & 0 deletions src/assert-never/assert-never.ts
Original file line number Diff line number Diff line change
@@ -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)}`,
),
);
};
Loading