docs(omo-codex): batch 76 (7 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:13 +09:00
parent bb815cc274
commit cdefdcf66e
7 changed files with 1785 additions and 0 deletions
@@ -0,0 +1,195 @@
# TypeScript Programmer
Modern TypeScript. Type-strict, stack-first, async-correct.
## Philosophy
The compiler is your proof system. Make illegal states unrepresentable. Parse at boundaries. Every function has a contract; the type system enforces it.
## Hard rules
These are deliberate project choices. Violations are always wrong, not "style preferences".
### Tooling
| Category | Use | Never |
|---|---|---|
| Runtime | Bun (native TS, single binary) | ts-node, tsx |
| Package manager | `pnpm` | npm, yarn (unless workspace requires it) |
| Linter + formatter | Biome | ESLint, Prettier |
| Type checker | `tsc --noEmit` with strict config | skip type checking |
| Web framework | Hono | Express |
| Validation | Zod | joi, yup, class-validator |
| Testing | `bun test` or vitest | jest |
| ORM | Drizzle | TypeORM, Prisma (unless already in project) |
### The iron list
1. **Readonly by default** — all `type`/`interface` properties are `readonly`. Arrays are `readonly T[]`. Mutable only when mutation is the documented purpose.
2. **Branded types for distinct IDs**`type UserId = Brand<string, "UserId">`. Never pass raw `string` where a branded type exists.
3. **Exhaustive switch** — every `switch` on a discriminated union ends with `default: assertNever(x)`. No fall-through.
4. **No any**`any` is banned in annotations, returns, and parameters. Use `unknown` and narrow.
5. **No type assertions**`as any`, `as unknown` banned. `as const` and `satisfies` are fine.
6. **No non-null assertion**`x!` is banned. Use narrowing or optional chaining (`x?.y`).
7. **No @ts-ignore / @ts-expect-error** — fix the type.
8. **No enum** — use `as const` objects + literal union types.
9. **Zod at boundaries** — external input (API, user, file) → Zod schema. Internal → plain types.
10. **Typed errors** — Error subclasses with typed fields. No `throw new Error("bare string")` for domain errors. Use Result for expected failures within 1-2 call levels; throw for propagation across many layers.
11. **as const for constants** — module-level constant objects and arrays use `as const`.
12. **import type** — type-only imports use `import type`. Enforced by `verbatimModuleSyntax`.
13. **Named exports only** — no `export default`. Exception: framework requirement (Next.js pages, etc.).
14. **No empty catch, no catch-and-swallow** — every `catch` block must either (a) narrow the error with `instanceof` and handle each case, or (b) re-throw. Empty catch blocks and `catch (e) { console.error(e) }` without narrowing or re-throw are banned — they hide bugs. At top-level boundaries (CLI entry, HTTP handler), opt out with `// no-excuse-ok: catch`.
### Data modeling — which construct, when
| Situation | Use |
|---|---|
| User input, API request/response | Zod schema + `z.infer` |
| Internal value object | `type` with `readonly` properties |
| Function with multiple outcomes | Discriminated union (`kind` field) |
| Contract for implementations | `interface` |
| Fixed constants | `as const` + literal union |
| Distinct primitive (UserId vs OrderId) | Branded type |
| Key-value map | `Record<K, V>` or index signature |
**The one rule**: data crosses trust boundary → Zod. Everything else → plain `type` with `readonly`.
Load `data-modeling.md` for the full decision flowchart and comparison.
### When readonly does not apply
- **Framework state** (React `useState`, signals) — managed by framework.
- **Builder / accumulator** — object exists to be mutated (buffer, cache). Document why.
- **ORM mutations** — Drizzle insert/update objects.
### Why empty/unhandled catch is banned
In TypeScript, every `catch` receives `unknown`. The language gives you no type safety in catch blocks — you must earn it with `instanceof`. A bare `catch (e) { console.error(e) }` swallows `TypeError`, `RangeError`, and your domain errors identically. When a new error type appears, nothing warns you.
```typescript
// BANNED — empty catch
try { await fetchData() } catch {}
try { await fetchData() } catch (e) { /* will fix later */ }
// BANNED — catch-and-swallow (no narrowing, no rethrow)
try {
const data = await api.get("/users")
} catch (e) {
console.error("failed", e)
}
// GOOD — narrow with instanceof
try {
const data = await api.get("/users")
} catch (e) {
if (e instanceof HttpError) {
logger.warn(`API ${e.status}: ${e.message}`)
return fallback
}
throw e // unknown errors propagate
}
// GOOD — top-level boundary (only place catch-all is acceptable)
async function main(): Promise<void> { // no-excuse-ok: catch
try {
await run()
} catch (e) {
console.error("unhandled:", e)
process.exit(1)
}
}
```
### Libraries
| Domain | Library | Why |
|---|---|---|
| HTTP framework | Hono | Lightweight, multi-runtime, middleware, OpenAPI |
| Validation | Zod | Runtime validation + type inference |
| ORM | Drizzle | Type-safe SQL, no codegen |
| HTTP client | `ky` | Thin fetch wrapper (5KB); auto-throw on non-2xx, retry, timeout, hooks, prefixUrl. Browser + Node + Bun + Deno |
| HTTP client (perf) | `undici` (direct API) | When a Node backend needs connection pooling, HTTP/2, or pipelining |
> **HTTP client rule** - production code must not use bare `fetch()`. It has no retry, timeout, or error-handling policy and causes silent failures during incidents. Install **`ky`** by default, and use the **`undici`** direct API when a Node backend needs high-volume requests, connection pooling, HTTP/2, or pipelining. ~~`axios`~~ is forbidden after the supply-chain compromise (2026-03). `node-fetch` is unnecessary because Node 18+ includes built-in fetch.
| Testing | `bun test` / vitest | Fast, ESM-native |
| Logging | `pino` | Structured JSON, fast |
| CLI | `@clack/prompts` + `commander` | Interactive + parsing |
## tsconfig — the one true config
Scaffold a new project with all strict defaults pre-configured:
```bash
bun run ../../scripts/typescript/new-project.ts my-api
bun run ../../scripts/typescript/new-project.ts my-api --path ./projects
```
Creates: `package.json` (Hono + Zod + Biome), `tsconfig.json` (ultra-strict), `biome.json`, `src/index.ts`, `.gitignore`. Works on macOS, Linux, Windows.
For manual setup: `bunx tsc --init`, then load `tsconfig-strict.md` for the full strict config.
Key flags beyond `"strict": true`:
| Flag | What it catches |
|---|---|
| `noUncheckedIndexedAccess` | `arr[0]` is `T \| undefined`, forces check |
| `exactOptionalPropertyTypes` | `{ x?: string }``{ x: string \| undefined }` |
| `verbatimModuleSyntax` | Forces `import type` for type-only imports |
| `noFallthroughCasesInSwitch` | Forgotten `break` / `return` |
| `noPropertyAccessFromIndexSignature` | `.key` on index sig → bracket notation |
## Reference loading
Load on demand — not all at once.
| Need | Load |
|---|---|
| Strict tsconfig + Biome config | `tsconfig-strict.md` |
| Type patterns (branded, as const, satisfies, narrowing, assertNever) | `type-patterns.md` |
| Data modeling (type vs interface vs Zod, readonly, parse-don't-validate) | `data-modeling.md` |
| Error handling (Result, typed errors, union vs throw) | `error-handling.md` |
| Bootstrapping a new project (Bun, pnpm, Hono, Vite) | `bootstrap.md` |
| Hono backend stack (hono-openapi, Scalar, Swagger) | `backend-hono.md` |
## No-excuse audit
Violations caught by `../../scripts/typescript/check-no-excuse-rules.ts`. Run after every edit session.
| Rule ID | Catches | Opt-out |
|---|---|---|
| `no-any-assertion` | `as any` | None — redesign types |
| `no-unknown-assertion` | `as unknown` | None — redesign types |
| `no-ts-ignore` | `@ts-ignore` | None — fix the type |
| `no-ts-expect-error` | `@ts-expect-error` | None — fix the type |
| `no-enum` | `enum` declarations | None — use `as const` |
| `no-non-null-assertion` | `x!` postfix | None — narrow or `?.` |
| `no-throw-literal` | `throw "string"` / `throw 123` | None — throw Error subclass |
| `no-mutable-export` | `export let` / `export var` | None — use `export const` |
| `no-any-annotation` | `: any` in parameter/return/variable types | `// no-excuse-ok: any` |
| `no-explicit-any-return` | `(): any` or `(): Promise<any>` return types | `// no-excuse-ok: any` |
| `empty-catch` | `catch { }` or `catch (e) { }` with empty body | `// no-excuse-ok: catch` |
| `catch-without-narrowing` | `catch (e)` used without `instanceof` or re-throw | `// no-excuse-ok: catch` |
Biome enforces additional rules (noExplicitAny, noNonNullAssertion, noDefaultExport, useImportType). The script catches what Biome cannot.
## In tests
Tests are strict too, with these exceptions (configure in `biome.jsonc` overrides):
| In tests you may | Why |
|---|---|
| Use `expect()` assertions | That's how testing works |
| Use magic numbers | Test data |
| Access private members via bracket notation | Testing internals |
| Skip readonly on test fixtures | Mutable setup/teardown |
Tests still follow the iron list — branded types, typed errors, exhaustive switch.
## Existing codebases
When editing an existing file that doesn't follow these rules: **write new code in strict style, don't refactor existing code in the same change.**
## Activation
This skill activates whenever you are writing or modifying any `.ts` or `.tsx` file. Even one-off scripts get the strict treatment.
@@ -0,0 +1,672 @@
# Hono Backend Stack Reference (2026)
> **Canonical stack**: `hono` + `hono-openapi` + `@scalar/hono-api-reference` + `@hono/swagger-ui`
> **Runtime**: Bun (TypeScript-first)
> **Validator**: Zod v4 (Standard Schema compliant, zero extra deps for OpenAPI)
---
## 1. Package Versions (Latest Stable)
| Package | Version | Source |
|---------|---------|--------|
| `hono` | `^4.12.5` | [peer dep of scalar](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/package.json#L66) |
| `hono-openapi` | `^1.3.0` | [npm](https://registry.npmjs.org/hono-openapi) — published Mar 2, 2026 |
| `@scalar/hono-api-reference` | `^0.10.11` | [npm](https://www.npmjs.com/package/@scalar/hono-api-reference) — published Apr 28, 2026 |
| `@hono/swagger-ui` | `^0.6.1` | [npm](https://www.npmjs.com/package/@hono/swagger-ui) — published Apr 2026 |
| `zod` | `^4.4.1` | [npm registry](https://registry.npmjs.org/zod) — latest stable v4 |
### `package.json` dependency block
```json
{
"dependencies": {
"hono": "^4.12.5",
"hono-openapi": "^1.3.0",
"@scalar/hono-api-reference": "^0.10.11",
"@hono/swagger-ui": "^0.6.1",
"zod": "^4.4.1"
},
"devDependencies": {
"typescript": "^5.8.0",
"@types/bun": "latest"
}
}
```
> **Peer dependencies auto-installed by `hono-openapi`**:
> - `@hono/standard-validator@^0.2.0`
> - `@standard-community/standard-json@^0.3.5`
> - `@standard-community/standard-openapi@^0.2.9`
> - `openapi-types@^12.1.3`
>
> [Source: `hono-openapi/package.json` peerDependencies](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/package.json#L50-L65)
---
## 2. Complete `app.ts` — Copy-Pasteable
```typescript
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler, resolver, validator } from 'hono-openapi'
import { Scalar } from '@scalar/hono-api-reference'
import { swaggerUI } from '@hono/swagger-ui'
import { z } from 'zod'
// ───────────────────────────────────────────────────────────────
// 1. Schema definitions (Zod v4 — Standard Schema native)
// ───────────────────────────────────────────────────────────────
const QuerySchema = z.object({
name: z.string().optional(),
})
const ResponseSchema = z.object({
message: z.string(),
})
const JsonBodySchema = z.object({
name: z.string(),
age: z.number().int().min(0),
})
// ───────────────────────────────────────────────────────────────
// 2. Hono app with described routes
// ───────────────────────────────────────────────────────────────
const app = new Hono()
// Health check (no validation)
app.get('/health', (c) => c.json({ status: 'ok' }))
// A fully-documented route
app.get(
'/hello',
describeRoute({
tags: ['Greetings'],
summary: 'Say hello',
description: 'Returns a greeting message',
responses: {
200: {
description: 'Successful greeting',
content: {
'application/json': {
schema: resolver(ResponseSchema),
},
},
},
},
}),
validator('query', QuerySchema),
(c) => {
const query = c.req.valid('query')
return c.json({ message: `Hello ${query.name ?? 'Hono'}!` })
},
)
// A POST route with JSON body validation
app.post(
'/users',
describeRoute({
tags: ['Users'],
summary: 'Create a user',
responses: {
200: {
description: 'User created',
content: {
'application/json': {
schema: resolver(ResponseSchema),
},
},
},
},
}),
validator('json', JsonBodySchema),
(c) => {
const body = c.req.valid('json')
return c.json({ message: `Created user ${body.name}` })
},
)
// ───────────────────────────────────────────────────────────────
// 3. OpenAPI spec endpoint
// ───────────────────────────────────────────────────────────────
app.get(
'/openapi.json',
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'Hono API',
version: '1.0.0',
description: 'Example Hono API with OpenAPI',
},
servers: [
{ url: 'http://localhost:3000', description: 'Local server' },
],
},
}),
)
// ───────────────────────────────────────────────────────────────
// 4. Scalar API Reference UI
// ───────────────────────────────────────────────────────────────
app.get(
'/scalar',
Scalar({
url: '/openapi.json',
theme: 'saturn',
pageTitle: 'Hono API Reference',
}),
)
// ───────────────────────────────────────────────────────────────
// 5. Swagger UI (parallel mount)
// ───────────────────────────────────────────────────────────────
app.get(
'/swagger',
swaggerUI({
url: '/openapi.json',
title: 'Swagger UI',
}),
)
// ───────────────────────────────────────────────────────────────
// 6. Bun canonical entrypoint
// ───────────────────────────────────────────────────────────────
export default app
```
---
## 3. `hono-openapi` API Reference
### Import paths
**There is only one import path.** `hono-openapi` exports everything from its root:
```typescript
import {
describeRoute, // middleware to annotate a route with OpenAPI metadata
describeResponse, // attach response schemas directly to a handler
validator, // validation middleware (wraps @hono/standard-validator)
resolver, // wrap a Zod/Valibot/etc schema for OpenAPI responses
openAPIRouteHandler, // serve the generated OpenAPI JSON document
generateSpecs, // programmatically generate the spec (for build-time caching)
} from 'hono-openapi'
```
**Evidence** ([`src/index.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/index.ts#L1-L9)):
```typescript
export { generateSpecs, openAPIRouteHandler } from "./handler.js";
export {
describeResponse,
describeRoute,
loadVendor,
resolver,
validator,
} from "./middlewares.js";
```
> **No subpath exports** such as `hono-openapi/zod` or `hono-openapi/valibot`. The package uses Standard Schema and auto-detects the validator vendor.
### `describeRoute()` middleware
Attach OpenAPI metadata to any Hono route. Use `resolver()` for response body schemas.
```typescript
app.get(
'/path',
describeRoute({
tags: ['Users'],
summary: 'Get user',
description: 'Retrieve a single user by ID',
responses: {
200: {
description: 'User found',
content: {
'application/json': {
schema: resolver(UserSchema),
},
},
},
404: {
description: 'User not found',
},
},
}),
handler,
)
```
**Evidence** ([`src/middlewares.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L244-L254)):
```typescript
export function describeRoute(spec: DescribeRouteOptions): MiddlewareHandler {
const middleware: MiddlewareHandler = async (_c, next) => {
await next();
};
return Object.assign(middleware, {
[uniqueSymbol]: { spec },
});
}
```
### `validator()` middleware
Validates `query`, `json`, `param`, or `form` and **automatically** injects the request schema into the OpenAPI document. No manual `request` block in `describeRoute()` is required.
```typescript
validator('query', QuerySchema) // ?name=foo
validator('json', JsonBodySchema) // POST body
validator('param', ParamSchema) // /users/:id
validator('form', FormSchema) // multipart/form-data
```
**Evidence** ([`src/middlewares.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L199-L237)):
```typescript
export function validator<Schema extends StandardSchemaV1, ...>(
target: Target,
schema: Schema,
hook?: Hook<...>,
options?: ResolverReturnType["options"],
): MiddlewareHandler<E, P, V> {
const middleware = sValidator(target, schema, hook);
return Object.assign(middleware, {
[uniqueSymbol]: { target, ...resolver(schema, options), options },
});
}
```
### `openAPIRouteHandler()` — serving the spec
```typescript
app.get(
'/openapi.json',
openAPIRouteHandler(app, {
documentation: {
info: { title: 'Hono API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000' }],
},
}),
)
```
**Evidence** ([`src/handler.ts`](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/handler.ts#L42-L59)):
```typescript
export function openAPIRouteHandler<...>(
hono: Hono<E, S, P>,
options?: Partial<GenerateSpecOptions>,
): MiddlewareHandler<E, P, I> {
let specs: OpenAPIV3_1.Document;
return async (c) => {
if (specs) return c.json(specs);
specs = await generateSpecs(hono, options, c);
return c.json(specs);
};
}
```
> **Mount path convention**: `/openapi.json` is the most common. Some projects use `/openapi/spec.json` (e.g. [NamesMT/starter-monorepo](https://github.com/NamesMT/starter-monorepo/blob/main/apps/backend/src/openAPI.ts)).
---
## 4. `@scalar/hono-api-reference` Setup
### Import path and package name
```typescript
import { Scalar } from '@scalar/hono-api-reference'
```
> **Deprecated**: `apiReference` is still exported but deprecated in favor of `Scalar` ([PR #5297](https://github.com/scalar/scalar/pull/5297)).
**Evidence** ([`integrations/hono/src/index.ts`](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/src/index.ts#L1-L9)):
```typescript
import { Scalar } from './scalar'
export {
Scalar,
/**
* @deprecated Use `Scalar` instead.
*/
Scalar as apiReference,
}
```
### Mount path convention
Common choices:
- `/scalar` — matches the middleware name
- `/docs` — generic documentation endpoint
- `/openapi/ui` — nested under the OpenAPI prefix
### Configuration options
The Hono middleware accepts the **universal Scalar configuration** plus Hono-specific overrides (`pageTitle`, `cdn`).
```typescript
app.get('/scalar', Scalar({
// ── Source (required) ──
url: '/openapi.json', // URL to the OpenAPI spec
// ── Appearance ──
theme: 'saturn', // 'alternate' | 'default' | 'moon' | 'purple'
// | 'solarized' | 'bluePlanet' | 'deepSpace'
// | 'saturn' | 'kepler' | 'elysiajs' | 'fastify'
// | 'mars' | 'laserwave' | 'none'
pageTitle: 'My API Docs', // HTML <title>
customCss: '.sidebar { ... }', // injected <style> block
metaData: { title: '...' }, // SEO meta tags (unhead format)
favicon: '/favicon.svg',
// ── Behavior ──
layout: 'modern', // 'modern' | 'classic'
darkMode: true,
forceDarkModeState: 'dark', // 'dark' | 'light'
hideDarkModeToggle: false,
hideModels: false,
hideSearch: false,
hideTestRequestButton: false,
showOperationId: false,
showSidebar: true,
// ── Proxy / Server ──
proxyUrl: 'https://proxy.scalar.com',
baseServerURL: 'http://localhost:3000',
servers: [{ url: 'http://localhost:3000' }],
// ── CDN ──
cdn: 'https://cdn.jsdelivr.net/npm/@scalar/api-reference',
// ── Advanced ──
authentication: { ... },
hiddenClients: ['unirest'],
defaultHttpClient: { targetKey: 'js', clientKey: 'fetch' },
plugins: [...],
pathRouting: { basePath: '/reference' },
mcp: { name: 'My MCP', url: '...' },
}))
```
**Evidence** — Scalar types define the full schema:
- [Base configuration (themes, proxy, etc.)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/base-configuration.ts#L110-L129)
- [HTML rendering configuration (`pageTitle`, `cdn`)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/html-rendering-configuration.ts#L8-L23)
- [Source configuration (`url`, `content`)](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/source-configuration.ts#L8-L55)
- [Full API reference configuration](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/packages/types/src/api-reference/api-reference-configuration.ts#L22-L379)
### Dynamic configuration (request-aware)
```typescript
app.get('/scalar', Scalar((c) => ({
url: '/openapi.json',
proxyUrl: c.env.ENVIRONMENT === 'development'
? 'https://proxy.scalar.com'
: undefined,
})))
```
**Evidence** ([`integrations/hono/src/scalar.ts`](https://github.com/scalar/scalar/blob/8bcf8bf52a0da667d44eeec08648e3b1da044f97/integrations/hono/src/scalar.ts#L75-L94)):
```typescript
export const Scalar = <E extends Env>(configOrResolver: Configuration<E>): MiddlewareHandler<E> => {
return async (c) => {
let resolvedConfig: Partial<ApiReferenceConfiguration> = {}
if (typeof configOrResolver === 'function') {
resolvedConfig = await configOrResolver(c)
} else {
resolvedConfig = configOrResolver
}
// ...
}
}
```
---
## 5. `@hono/swagger-ui` Setup
### Import path and package name
```typescript
import { swaggerUI } from '@hono/swagger-ui'
```
**Evidence** ([`packages/swagger-ui/src/index.ts`](https://github.com/honojs/middleware/blob/eb443a2fbda674bbe12d3f30e96854bb0cad6232/packages/swagger-ui/src/index.ts#L93)):
```typescript
export { middleware as swaggerUI, SwaggerUI }
```
### Mount path convention
Common choices:
- `/swagger` — explicit
- `/ui` — used in Hono official examples
- `/docs` — generic
### Configuration options
```typescript
app.get('/swagger', swaggerUI({
url: '/openapi.json', // URL to the OpenAPI spec (required)
title: 'Swagger UI', // HTML page title
version: 'latest', // Swagger UI CDN version
// Any standard Swagger UI option also works:
// presets, plugins, urls, etc.
}))
```
**Evidence** ([`packages/swagger-ui/src/index.ts`](https://github.com/honojs/middleware/blob/eb443a2fbda674bbe12d3f30e96854bb0cad6232/packages/swagger-ui/src/index.ts#L8-L43)):
```typescript
type SwaggerUIOptions = OriginalSwaggerUIOptions & DistSwaggerUIOptions
const middleware = <E extends Env>(options: SwaggerUIOptions): MiddlewareHandler<E> =>
async (c) => {
const title = options?.title ?? 'SwaggerUI'
return c.html(/* html */ `...`)
}
```
---
## 6. Bun Runtime Entrypoint
### Canonical shape for `bun run`
```typescript
import { Hono } from 'hono'
const app = new Hono()
// ... routes ...
export default app
```
**Evidence** ([Hono Bun docs](https://hono.dev/docs/getting-started/bun)):
> ```ts
> import { Hono } from 'hono'
> const app = new Hono()
> app.get('/', (c) => c.text('Hello Bun!'))
> export default app
> ```
### Custom port
```typescript
export default {
port: 3000,
fetch: app.fetch,
}
```
**Evidence** ([Hono Bun docs — Change port number](https://hono.dev/docs/getting-started/bun)):
> ```ts
> export default {
> port: 3000,
> fetch: app.fetch,
> }
> ```
### `package.json` scripts for Bun
```json
{
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"build": "tsc --noEmit"
}
}
```
---
## 7. OpenAPI Version
### `hono-openapi` emits **OpenAPI 3.1.0** by default
This is **hardcoded** in the source and **not configurable** at runtime:
**Evidence** ([`src/handler.ts` line 120](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/handler.ts#L120)):
```typescript
return {
openapi: "3.1.0",
..._documentation,
// ...
} satisfies OpenAPIV3_1.Document;
```
> If you need OpenAPI 3.0.x, you must post-process the generated spec or use `@hono/zod-openapi` (the older package) instead. The user explicitly requested `hono-openapi`, so document that 3.1.0 is the only output.
---
## 8. Zod v3 vs Zod v4
| Feature | Zod v3 | Zod v4 |
|---------|--------|--------|
| Standard Schema | ❌ No | ✅ Yes (native) |
| `hono-openapi` extra deps | `zod-openapi@4` | None |
| Import path | `import { z } from 'zod'` | `import { z } from 'zod'` (or `zod/v4` for explicit) |
**For Zod v3 users**, install the compatibility layer:
```bash
npm install zod-openapi@4
```
Then use `zod-openapi`'s `.openapi()` for metadata and `.meta({ ref: 'Name' })` for component references. `hono-openapi`'s `resolver()` will still work, but the underlying schema conversion relies on `zod-openapi@4`.
**Evidence** ([HonoHub Zod docs](https://honohub.dev/docs/openapi/zod)):
> "For zod v3, you can use the `zod-openapi` library. You need to install `zod-openapi@4` for this to work properly."
**For Zod v4 users** (recommended in 2026), no extra packages are needed. `z.date()` is automatically converted to `{ type: 'string', format: 'date-time' }`.
**Evidence** ([`src/middlewares.ts` Zod v4 date override](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/middlewares.ts#L63-L71)):
```typescript
const zodV4DateOverride = (ctx: { ... }) => {
if (ctx.zodSchema._zod.def.type === "date") {
ctx.jsonSchema.type = "string";
ctx.jsonSchema.format = "date-time";
}
};
```
---
## 9. Real-World Example
**NamesMT/starter-monorepo** — a public monorepo starter using `hono-openapi` + `@scalar/hono-api-reference` together:
- File: [`apps/backend/src/openAPI.ts`](https://github.com/NamesMT/starter-monorepo/blob/main/apps/backend/src/openAPI.ts)
- Pattern: mounts spec at `/openapi/spec.json` and Scalar UI at `/openapi/ui`
```typescript
import type { Hono } from 'hono'
import { Scalar } from '@scalar/hono-api-reference'
import { openAPIRouteHandler } from 'hono-openapi'
export function setupOpenAPI(app: Hono<any, any>, prefix = '/openapi') {
app.get(
`${prefix}/spec.json`,
openAPIRouteHandler(app, {
documentation: {
info: {
title: `starter-monorepo's backend`,
version: '1.0.0',
description: 'My amazing API',
},
},
}),
)
app.get(
`${prefix}/ui`,
Scalar({
theme: 'deepSpace',
url: `${prefix}/spec.json`,
}),
)
}
```
> **Note**: No public repo was found using all four (`hono-openapi` + `Scalar` + `swagger-ui` + `hono`) in a single file. The canonical combination in the wild is `hono-openapi` + `Scalar`. Adding `swagger-ui` is a trivial parallel mount (shown in the `app.ts` above).
---
## 10. Common Pitfalls
1. **Using `openAPISpecs` instead of `openAPIRouteHandler`**
Some docs (e.g. HONC) use `openAPISpecs` — this is **not** the current export name. The correct function is `openAPIRouteHandler` ([source](https://github.com/rhinobase/hono-openapi/blob/10f45a66ede3764b5e6065805fb60fd5df090466/src/index.ts#L1)).
2. **Importing from `hono-openapi/zod`**
There are **no subpath exports**. Always import from `hono-openapi` directly.
3. **Forgetting `@hono/standard-validator`**
It is a peer dependency of `hono-openapi`. Modern package managers (npm ≥ 7, pnpm, bun) auto-install it. If you see validation errors, ensure it is present in `node_modules`.
4. **Using `@hono/zod-openapi` (the OLD package)**
The user explicitly wants `hono-openapi` (the newer, middleware-based, Standard Schema package). Do not confuse with `@hono/zod-openapi` which wraps the `Hono` class into `OpenAPIHono`.
5. **Swagger UI `spec` option**
`@hono/swagger-ui` does **not** accept a `spec` option to embed the document directly. It only accepts `url` (or `urls`) pointing to an external spec endpoint. If you need embedded specs, use Scalar's `content` option instead.
---
## 11. Quick Start Commands
```bash
# 1. Create project
mkdir my-api && cd my-api
bun init -y
# 2. Install dependencies
bun add hono hono-openapi @scalar/hono-api-reference @hono/swagger-ui zod
# 3. Add TypeScript
bun add -d typescript @types/bun
# 4. Write app.ts (copy from section 2 above)
# 5. Run
bun run --hot app.ts
```
Endpoints after startup:
- `GET /health` — health check
- `GET /hello?name=world` — documented route
- `POST /users` — validated JSON body route
- `GET /openapi.json` — raw OpenAPI 3.1.0 spec
- `GET /scalar` — Scalar API Reference UI
- `GET /swagger` — Swagger UI
@@ -0,0 +1,199 @@
# Bootstrap — Runtime, Package Manager, Tooling
When starting a new TypeScript project (or scripting against the world), the choice of runtime, package manager, framework, and toolchain compounds. The wrong default at minute zero costs hours every week. The right defaults for 2026:
## Runtime decision tree
```
Is this a CLI / script / single-binary tool?
└─ Yes → Bun (single executable, hot reload, native TS)
Use `bun run script.ts` directly. No build step.
Is this a backend service?
├─ Edge (Cloudflare Workers / Vercel / Deno Deploy) → match the platform
├─ Bun-supported runtime → Bun + Hono
├─ Need Node-only deps (sharp, native modules without Bun support) → Node + Hono
└─ Otherwise → Bun + Hono
Is this a frontend?
└─ Vite (regardless of framework). Bun for the package manager.
Is this a library to publish to npm?
└─ tsdown (or unbuild). Targets Node 20+. Use pnpm for monorepo workspaces.
```
## Bun is the default runtime
Use Bun for:
- Scripts and CLIs (`bun run` is faster than `tsx` and `ts-node`)
- New backends (Hono runs natively, hot reload via `bun --hot`)
- Test runner (`bun test` is built-in, faster than vitest for small suites)
- Package manager (`bun install` is faster than `pnpm` and far faster than `npm`)
Use Node when:
- A dependency uses native modules Bun can't load (rare in 2026; check the dep's release notes)
- Production target is a Node-specific platform (some serverless platforms don't run Bun yet)
- You're contributing to a Node-only project
`bunx` replaces `npx`. `bun create` scaffolds projects.
## Package manager — pnpm > npm
If you must use Node, use pnpm. NEVER npm except in legacy projects you don't control.
Why pnpm:
- Content-addressable store: 10x less disk usage on a machine with many projects
- Strict node_modules layout: phantom dependencies fail at install time, not at runtime
- Workspaces are first-class
- Significantly faster than npm
Why not yarn:
- Yarn classic is unmaintained
- Yarn berry's "PnP" mode breaks with editor tooling more often than it should
- pnpm has caught up on every yarn berry feature people actually use
Why not npm:
- Slowest of the three
- No proper workspace story until very recently
- Phantom dependencies allowed by default
```bash
# Convert npm/yarn → pnpm
pnpm import # reads package-lock.json or yarn.lock and produces pnpm-lock.yaml
rm -rf node_modules package-lock.json yarn.lock
pnpm install
```
## Backend framework — Hono
Use Hono for any new HTTP service. It is:
- Type-safe end-to-end (request/response types flow through middleware)
- Edge-compatible (runs on Bun, Node, Cloudflare Workers, Deno, AWS Lambda)
- Faster than Express, Fastify, and most of its peers in synthetic benchmarks
- Maintained, opinionated, and documented well
When Hono → ALWAYS pair with `hono-openapi` + `@scalar/hono-api-reference` + `@hono/swagger-ui`. Full setup with copy-pasteable `app.ts`: [backend-hono.md](backend-hono.md).
NEVER:
- Express for new services. Express is the COBOL of Node — works, but writes itself out of every benchmark.
- Fastify for new services. Hono ships with better TypeScript ergonomics.
- NestJS for new services. The Angular-flavoured DI/decorator stack is overkill for ~95% of services.
- Bare `Bun.serve` or `node:http` unless you have a specific reason. Lose middleware, routing, validation. Reinvent everything.
## Frontend tooling — Vite
Vite for any frontend. Replaces webpack, parcel, rollup-as-app-bundler. Works with React, Vue, Svelte, Solid, Preact, vanilla.
```bash
bun create vite my-app -- --template react-ts
cd my-app
bun install
bun run dev
```
## Lint + format — Biome
Biome replaces ESLint + Prettier with one tool, written in Rust, ~30x faster.
```bash
bun add --dev @biomejs/biome
bun biome init
```
`biome.json`:
```json
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": { "enabled": true, "rules": { "recommended": true } },
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }
}
```
Use ESLint only when:
- You have an ESLint plugin Biome doesn't replicate (rare in 2026)
- You're contributing to an existing ESLint project
Never run both — pick one.
## Test runner — bun test or vitest
| Runner | Use when |
|---|---|
| `bun test` | Bun project, simple unit tests, no TypeScript path aliases that need vite-style resolution |
| `vitest` | Vite-based frontend, complex test infrastructure (DOM testing, snapshot, in-browser tests), or you need vitest-specific features |
NEVER Jest for a new project. Jest's CommonJS-first design fights every modern Node/TS project.
## TypeScript
`tsconfig.json` for a Bun + Hono backend:
```json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*", "tests/**/*"]
}
```
`verbatimModuleSyntax: true` enforces explicit `import type { ... }` for type-only imports — pairs with the no-excuse rule on type-only imports.
`noEmit: true` because `bun run` and `bun build` handle compilation. The `tsc` command becomes a typechecker only.
## Quick-start: Bun + Hono backend
```bash
mkdir my-api && cd my-api
bun init -y
bun add hono hono-openapi @scalar/hono-api-reference @hono/swagger-ui zod
bun add --dev @biomejs/biome typescript
bun biome init
```
`package.json` scripts:
```json
{
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --target bun --outdir dist",
"typecheck": "tsc --noEmit",
"lint": "biome check --write src tests",
"test": "bun test"
}
}
```
Wire the `app.ts` from [backend-hono.md](backend-hono.md). You have a documented, validated, OpenAPI-spec-emitting service in ~15 minutes.
## When NOT to bootstrap from scratch
| Situation | Use |
|---|---|
| Internal tool with auth/admin/dashboards | Next.js (full-stack) - lots of free wiring |
| Documentation site | Astro or VitePress |
| Real-time features (WebRTC, complex sockets) | Bun + Hono + a real-time library |
| Data-heavy SPA | Vite + React + TanStack Query + TanStack Router |
For greenfield backend services, Bun + Hono. Always.
@@ -0,0 +1,202 @@
# Data Modeling
Which construct to use, how to structure data, and why readonly is the default.
---
## Decision flowchart
```
Is it a fixed set of named constants?
YES → as const object + literal union type
NO ↓
Is it just branding a primitive (string, number)?
YES → Branded type
NO ↓
Is it an interface / contract?
YES → interface (structural typing is the default in TS)
NO ↓
Does the data cross a trust boundary (user input, API, file)?
YES → Zod schema + z.infer<typeof schema>
NO ↓
Is it a union of possible outcomes?
YES → Discriminated union (kind/type field)
NO ↓
Is it structured data with named fields?
YES → type alias with readonly properties
NO → you probably don't need a new type
```
---
## Container reference
### type alias — internal data
The default for structured data inside your codebase. Zero runtime cost.
```typescript
type User = {
readonly id: UserId
readonly name: string
readonly email: string
}
type Point = {
readonly x: number
readonly y: number
}
```
All properties `readonly`. Mutable only when mutation is the documented purpose.
### interface — contracts and extension
Use when you need declaration merging or `extends`.
```typescript
interface Repository<T> {
get(id: string): Promise<T | null>
save(entity: T): Promise<void>
}
interface UserRepository extends Repository<User> {
findByEmail(email: string): Promise<User | null>
}
```
### interface vs type — when to use which
| Use | When |
|---|---|
| `type` | Union types, intersections, mapped types, utility types, internal data shapes |
| `interface` | Contracts that will be `implements`ed or `extends`ed, declaration merging needed |
| **Default** | **`type` — unless you have a specific reason for `interface`** |
### Zod schema — trust boundary guardian
Use when data enters your system. Validates at runtime, infers types at compile time.
```typescript
import { z } from "zod"
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0),
})
type CreateUser = z.infer<typeof CreateUserSchema>
const UserResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
})
type UserResponse = z.infer<typeof UserResponseSchema>
```
**The one rule**: data crosses a trust boundary → Zod. Everything else → plain type/interface.
Never use Zod for internal-only data. The runtime validation cost and Zod coupling are unnecessary.
### as const — fixed constants
Replaces `enum` entirely. Type-safe, tree-shakeable, no runtime overhead.
```typescript
const ROLES = ["admin", "user", "guest"] as const
type Role = (typeof ROLES)[number]
const STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
DELETED: "deleted",
} as const
type Status = (typeof STATUS)[keyof typeof STATUS]
```
### Discriminated union — multiple outcomes
```typescript
type GetUserResult =
| { readonly kind: "found"; readonly user: User }
| { readonly kind: "not_found"; readonly id: UserId }
| { readonly kind: "forbidden"; readonly reason: string }
```
Each variant has a `kind` discriminant. TypeScript narrows on `switch (result.kind)`.
---
## Quick lookup
| Situation | Use |
|---|---|
| User input, API request/response | Zod schema + `z.infer` |
| Internal value object | `type` with `readonly` properties |
| Function with multiple outcomes | Discriminated union |
| Contract for implementations | `interface` |
| Fixed constants | `as const` + literal union |
| Distinct primitive (UserId vs OrderId) | Branded type |
| Dict shape / key-value map | `Record<K, V>` or index signature |
---
## Readonly by default
Every property is `readonly` unless mutation is the documented purpose.
```typescript
// DEFAULT — readonly
type Config = {
readonly apiUrl: string
readonly timeout: number
}
// Arrays too
function getUsers(): readonly User[] { ... }
// Utility for existing types
type ReadonlyUser = Readonly<User>
type DeepReadonlyConfig = Readonly<Config>
```
For mutable state (rare), document why:
```typescript
/** Counter state — mutation is the entire purpose. */
type CounterState = {
count: number // intentionally mutable
}
```
---
## Parse, don't validate
Validate at the boundary. Inside the boundary, types are proof of validity.
```typescript
// BAD — validate then pass raw data
function processEmail(email: string): void {
if (!email.includes("@")) throw new Error("invalid")
// still a raw string downstream
}
// GOOD — parse into typed value at boundary
const EmailSchema = z.string().email().brand("Email")
type Email = z.infer<typeof EmailSchema>
function sendWelcome(email: Email): void { ... }
// Boundary code
const parsed = EmailSchema.parse(rawInput) // Email or throws
sendWelcome(parsed) // no re-validation needed
```
---
## Sources
- TypeScript Handbook: [Object Types](https://www.typescriptlang.org/docs/handbook/2/objects.html)
- Zod: [docs](https://zod.dev)
- Total TypeScript: [Type vs Interface](https://www.totaltypescript.com/type-vs-interface-which-should-you-use)
@@ -0,0 +1,169 @@
# Error Handling
Typed errors, exhaustive matching, Result pattern, and resource safety.
---
## Typed errors — no bare strings
Error classes carry structured data. Callers know exactly what can go wrong.
```typescript
class UserNotFoundError extends Error {
readonly name = "UserNotFoundError"
constructor(readonly userId: UserId) {
super(`user ${userId} not found`)
}
}
class PermissionDeniedError extends Error {
readonly name = "PermissionDeniedError"
constructor(
readonly userId: UserId,
readonly requiredRole: string,
) {
super(`user ${userId} needs role ${requiredRole}`)
}
}
```
```typescript
// BAD
throw new Error("user not found")
throw new Error("permission denied")
// GOOD
throw new UserNotFoundError(userId)
throw new PermissionDeniedError(userId, "admin")
```
Always set `readonly name` explicitly — `instanceof` checks survive minification, but `error.name` is more reliable for logging and serialization.
---
## Result pattern — expected failures without exceptions
For failures that are **expected** (not found, validation), return a discriminated union instead of throwing.
```typescript
type Result<T, E = Error> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E }
function ok<T>(value: T): Result<T, never> {
return { ok: true, value }
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error }
}
```
### Usage
```typescript
type UserError =
| { readonly kind: "not_found"; readonly id: UserId }
| { readonly kind: "forbidden"; readonly reason: string }
function getUser(id: UserId): Result<User, UserError> {
const user = db.find(id)
if (!user) return err({ kind: "not_found", id })
if (!user.active) return err({ kind: "forbidden", reason: "deactivated" })
return ok(user)
}
// Caller must handle both cases
const result = getUser(userId)
if (!result.ok) {
switch (result.error.kind) {
case "not_found":
log.warn(`missing: ${result.error.id}`)
break
case "forbidden":
log.error(`denied: ${result.error.reason}`)
break
default:
assertNever(result.error)
}
return
}
const user = result.value // narrowed to User
```
### When to use which
**The heuristic**: caller is 1-2 levels away and MUST handle it → Result. Error should propagate up many layers → throw.
| Scenario | Pattern | Why |
|---|---|---|
| Repository → service (caller handles it) | Result | Caller is right there, must handle both |
| Validation at boundary (parsing input) | throw (Zod throws) | Propagates up to HTTP handler |
| Infrastructure failure (network, OOM) | throw | Can't handle locally |
| Service → service (deep internal) | throw (typed Error subclass) | Result boilerplate across many layers is worse |
| HTTP handler → response | Catch errors, convert to response | Boundary code catches and translates |
**Practical tradeoff**: Result is safest (compiler forces handling) but creates boilerplate when every caller in a chain must check `.ok`. If the error would just propagate through 3+ layers unchanged, use a typed Error subclass instead.
### Library or roll your own?
Roll your own with the `Result`, `ok`, `err` above. It's 10 lines. Libraries like `neverthrow` add chaining (`.map`, `.andThen`) — use them only if you actually chain results frequently.
---
## Error cause — chain context
Use the `cause` option to chain errors without losing the original stack.
```typescript
try {
await db.query(sql)
} catch (error) {
throw new DatabaseError("query failed", { cause: error })
}
```
The `cause` is available on `error.cause` and shows up in stack traces.
---
## Exhaustive error handling at boundaries
HTTP handlers catch and translate:
```typescript
app.onError((error, c) => {
if (error instanceof UserNotFoundError) {
return c.json({ error: error.message }, 404)
}
if (error instanceof PermissionDeniedError) {
return c.json({ error: error.message }, 403)
}
console.error("unhandled:", error)
return c.json({ error: "internal server error" }, 500)
})
```
---
## Async error patterns
```typescript
// Promise.allSettled — when partial failure is OK
const results = await Promise.allSettled(urls.map(fetch))
const successes = results
.filter((r): r is PromiseFulfilledResult<Response> => r.status === "fulfilled")
.map((r) => r.value)
// AbortSignal — cancellation
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
return fetch(url, { signal: AbortSignal.timeout(ms) })
}
```
---
## Sources
- MDN: [Error cause](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
- MDN: [Promise.allSettled](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled)
@@ -0,0 +1,152 @@
# Strict tsconfig + Biome
The canonical ultra-strict config. Copy-paste, then add your own paths.
---
## tsconfig.json
```jsonc
{
"compilerOptions": {
// ── Strict core ──────────────────────────────────────────
"strict": true, // enables all strict* flags below
// strict includes: strictNullChecks, strictFunctionTypes,
// strictBindCallApply, strictPropertyInitialization,
// noImplicitAny, noImplicitThis, alwaysStrict, useUnknownInCatchVariables
// ── Additional strict flags (NOT included in "strict") ──
"noUncheckedIndexedAccess": true, // obj[key] is T | undefined, not T
"exactOptionalPropertyTypes": true, // { x?: string } !== { x: string | undefined }
"noFallthroughCasesInSwitch": true, // switch fall-through is an error
"noPropertyAccessFromIndexSignature": true, // forces bracket notation for index sigs
"forceConsistentCasingInFileNames": true, // prevents case-sensitivity bugs on macOS/Win
// ── Module system ────────────────────────────────────────
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true, // forces `import type` for type-only imports
"isolatedModules": true, // safe for esbuild / swc / Bun transpilation
"esModuleInterop": true,
"resolveJsonModule": true,
// ── Target ───────────────────────────────────────────────
"target": "ESNext",
"lib": ["ESNext"],
// ── Emit ─────────────────────────────────────────────────
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
// ── Performance ──────────────────────────────────────────
"skipLibCheck": true, // skip checking .d.ts files for speed
"incremental": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
```
### What each extra flag catches
| Flag | What it prevents |
|---|---|
| `noUncheckedIndexedAccess` | `arr[0]` is `T \| undefined`, not `T`. Forces you to check before using. |
| `exactOptionalPropertyTypes` | `{ x?: string }` means "missing or string", NOT "string \| undefined". Assigns `undefined` explicitly? Type error. |
| `noFallthroughCasesInSwitch` | Forgetting `break` / `return` in a switch case. |
| `noPropertyAccessFromIndexSignature` | `obj.foo` on `Record<string, X>` is an error. Use `obj["foo"]`. |
| `verbatimModuleSyntax` | Forces `import type { X }` for type-only imports. Prevents runtime import of types. |
### Bun-specific additions
For Bun projects, add to `compilerOptions`:
```jsonc
{
"types": ["bun-types"],
"moduleDetection": "force"
}
```
---
## biome.jsonc
```jsonc
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error",
"noConfusingVoidType": "error",
"noFallthroughSwitchClause": "error"
},
"style": {
"noDefaultExport": "error",
"useImportType": "error",
"noNonNullAssertion": "error",
"useEnumInitializers": "off",
"noParameterAssign": "error"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
},
"complexity": {
"noBannedTypes": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "asNeeded"
}
},
"files": {
"ignore": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage"]
}
}
```
### Key Biome rules
| Rule | What |
|---|---|
| `noExplicitAny` | `any` in annotations is an error |
| `noNonNullAssertion` | `x!` is an error |
| `noDefaultExport` | Forces named exports |
| `useImportType` | Forces `import type` for type-only imports |
| `noParameterAssign` | No mutation of function parameters |
---
## CI gate
```bash
bunx biome check .
bunx tsc --noEmit
bun test
```
---
## Sources
- TypeScript: [tsconfig reference](https://www.typescriptlang.org/tsconfig)
- Biome: [configuration](https://biomejs.dev/reference/configuration/)
- Total TypeScript: [tsconfig cheat sheet](https://www.totaltypescript.com/tsconfig-cheat-sheet)
@@ -0,0 +1,196 @@
# Type Patterns
How to use TypeScript's type system to catch bugs at compile time.
---
## Branded types — distinct primitives
Same runtime type, different meaning. The compiler prevents mixing.
```typescript
declare const brand: unique symbol
type Brand<T, B extends string> = T & { readonly [brand]: B }
type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">
type Milliseconds = Brand<number, "Milliseconds">
type Seconds = Brand<number, "Seconds">
function UserId(value: string): UserId { return value as UserId }
function OrderId(value: string): OrderId { return value as OrderId }
function getUser(id: UserId): User { ... }
getUser(UserId("abc")) // OK
getUser(OrderId("abc")) // type error: OrderId is not UserId
getUser("abc") // type error: string is not UserId
```
With Zod (preferred at boundaries):
```typescript
import { z } from "zod"
const UserIdSchema = z.string().uuid().brand("UserId")
type UserId = z.infer<typeof UserIdSchema>
```
**Use when**: IDs, indices, units of measurement — any pair where swapping is a bug.
---
## as const — literal types from values
Freezes a value to its narrowest possible type. The foundation for enum-free TypeScript.
```typescript
const ROLES = ["admin", "user", "guest"] as const
type Role = (typeof ROLES)[number] // "admin" | "user" | "guest"
const HTTP_STATUS = {
OK: 200,
NOT_FOUND: 404,
INTERNAL: 500,
} as const
type HttpStatus = (typeof HTTP_STATUS)[keyof typeof HTTP_STATUS] // 200 | 404 | 500
```
**Use when**: fixed set of constants. Replaces `enum` entirely.
**Skip when**: the set is open-ended or user-defined.
---
## satisfies — validate without widening
Type-checks a value against a type while preserving the literal type. Best of both worlds.
```typescript
type Config = Record<string, string | number>
// BAD — widens to Record<string, string | number>
const config: Config = { api: "https://api.example.com", timeout: 30 }
config.api // string | number — lost the narrowing
// GOOD — validates AND preserves literal types
const config = {
api: "https://api.example.com",
timeout: 30,
} satisfies Config
config.api // string (narrowed)
config.timeout // number (narrowed)
```
**Use when**: you want type validation on a value without losing narrowing.
---
## Discriminated unions — algebraic data types
Model every outcome as a type. Force the caller to handle all cases.
```typescript
type GetUserResult =
| { readonly kind: "found"; readonly user: User }
| { readonly kind: "not_found"; readonly id: UserId }
| { readonly kind: "forbidden"; readonly reason: string }
```
The `kind` field (or `type`, `status`, `_tag`) is the discriminant. TypeScript narrows on it automatically.
---
## Exhaustive switch — assertNever
Every switch on a discriminated union ends with a default that calls `assertNever`.
```typescript
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`)
}
function handleResult(result: GetUserResult): string {
switch (result.kind) {
case "found":
return result.user.name
case "not_found":
return `No user ${result.id}`
case "forbidden":
return `Denied: ${result.reason}`
default:
return assertNever(result)
}
}
```
Add a new variant to `GetUserResult`? The compiler errors on the `assertNever` call until you handle it.
---
## Narrowing — let the compiler follow your logic
TypeScript narrows types through `typeof`, `instanceof`, `in`, equality checks, and discriminants.
```typescript
function process(value: string | number | null): string {
if (value === null) return "nothing"
// compiler knows: string | number
if (typeof value === "string") return value.toUpperCase()
// compiler knows: number
return String(value * 2)
}
```
### Custom type guards
```typescript
function isNonNull<T>(value: T | null | undefined): value is T {
return value != null
}
const items = [1, null, 2, undefined, 3]
const clean = items.filter(isNonNull) // number[]
```
---
## import type — separate values from types
Always use `import type` for type-only imports. Enforced by `verbatimModuleSyntax`.
```typescript
import type { User, Config } from "./types" // erased at runtime
import { createUser } from "./services" // kept at runtime
```
For mixed imports:
```typescript
import { createUser, type User } from "./users"
```
---
## Utility types — quick reference
| Need | Use |
|---|---|
| All properties readonly | `Readonly<T>` |
| All properties optional | `Partial<T>` |
| All properties required | `Required<T>` |
| Pick specific properties | `Pick<T, "a" \| "b">` |
| Omit specific properties | `Omit<T, "a" \| "b">` |
| Key-value map | `Record<K, V>` |
| Extract from union | `Extract<T, U>` |
| Exclude from union | `Exclude<T, U>` |
| Return type of function | `ReturnType<typeof fn>` |
| Parameters of function | `Parameters<typeof fn>` |
| Awaited type | `Awaited<Promise<T>>``T` |
---
## Sources
- TypeScript Handbook: [Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html)
- TypeScript Handbook: [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html)
- Total TypeScript: [as const](https://www.totaltypescript.com/as-const)