- name
- loom-typescript
- description
- TypeScript language expertise for type-safe, production-quality code.
- triggers
- ["typescript","ts","tsx","interface","generic","union","intersection","discriminated union","type guard","type assertion","utility types","conditional types","mapped types","satisfies","zod","trpc","prisma","react","node","nodejs","deno","bun","oxc","oxlint","oxfmt","rolldown","tsdown","vite","eslint","prettier","npm","pnpm","[Truncated]"]
# TypeScript Language Expertise
## Overview
Type-safe, production-quality TypeScript: the type system's sharp edges, strict-mode config that actually moves the needle, the type-vs-runtime boundary, and framework patterns (zod/tRPC/Prisma/React/Express). Assumes fluency with JS and basic TS — this is reference for the traps and idioms that bite experienced engineers.
Toolchain assumed throughout: **Bun** (install/run/test), **Vite** (app dev server and bundler — Rolldown bundling, Oxc transforms and minifier, Lightning CSS), **tsdown** (library bundler on Rolldown), **oxlint** (lint), **oxfmt** (format), `tsc --noEmit` (type-check). ESLint, Prettier, esbuild, and Babel are the legacy equivalents — do not add them to a project that has this stack. See **Toolchain** below.
## Type System Essentials
### Generics
```typescript
// Constraints + default type param; `this` return for fluent chaining
interface HasId { id: string }
class Repo<T extends HasId = HasId> {
private items = new Map<string, T>();
save(item: T): this { this.items.set(item.id, item); return this; }
find(id: string): T | undefined { return this.items.get(id); }
}
// Return `as const` to preserve a tuple/literal shape instead of widening
function pair<A, B>(a: A, b: B) { return [a, b] as const; } // readonly [A, B]
```
### Utility types
| Utility | Result |
| ----------------------------- | --------------------------------------------- |
| `Partial<T>` / `Required<T>` | all props optional / required |
| `Readonly<T>` | all props `readonly` (shallow) |
| `Pick<T,K>` / `Omit<T,K>` | keep / drop keys `K` |
| `Record<K,V>` | object with keys `K`, values `V` |
| `Extract<U,V>` / `Exclude<U,V>` | keep / drop union members of `U` assignable to `V` |
| `NonNullable<T>` | strip `null` / `undefined` |
| `ReturnType<F>` / `Parameters<F>` | function return type / param tuple |
| `Awaited<T>` | recursively unwrap `Promise` (prefer over a hand-rolled `Unwrap`) |
```typescript
// Derive types from values so they can't drift:
function createUser(name: string, email: string): User { /* … */ }
type NewUser = ReturnType<typeof createUser>; // User
type NewUserArgs = Parameters<typeof createUser>; // [string, string]
```
### Conditional types & `infer`
```typescript
type Elem<T> = T extends (infer U)[] ? U : never;
type Ret<T> = T extends (...a: any[]) => infer R ? R : never;
// Conditionals DISTRIBUTE over naked union type params:
type ToArray<T> = T extends any ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
// Wrap both sides in a 1-tuple to DISABLE distribution:
type ToArray1<T> = [T] extends [any] ? T[] : never;
type B = ToArray1<string | number>; // (string | number)[]
```
### Mapped types
```typescript
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]+?: T[K] };
// Key remapping via `as` (TS 4.1+): rename or filter keys
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };
// PickByValue<{a:string;b:number}, string> → { a: string }
```
### Discriminated unions & exhaustiveness
Model each valid state as a variant with a shared literal discriminant; `switch` on it narrows each arm, and an `assertNever` default turns "added a variant, forgot a case" into a compile error.
```typescript
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function assertNever(x: never): never { throw new Error(`Unreachable: ${JSON.stringify(x)}`); }
function render(s: State): string {
switch (s.status) {
case "idle": return "Click to load";
case "loading": return "Loading…";
case "success": return `Loaded ${s.data.length}`; // s narrowed → data exists
case "error": return s.error.message; // s narrowed → error exists
default: return assertNever(s); // ← new unhandled variant = type error
}
}
```
⚠ Exhaustiveness relies on a *finite* discriminant. A **numeric enum** discriminant accepts any `number`, so `assertNever` won't catch a missing case — use string-literal unions or `as const` objects (see Anti-Patterns).
### Type guards, assertion functions, `unknown` vs `any`
- `any` disables checking **transitively** — it silently poisons every expression it flows into. `unknown` is the safe top type: assignable *from* anything, assignable *to* nothing until you narrow.
- Type external inputs (`JSON.parse`, `fetch().json()`, `catch` vars, `process.env` shapes) as `unknown` and narrow with `typeof` / `instanceof` / `in` / a validator before use.
```typescript
function isCat(a: Cat | Dog): a is Cat { return "meow" in a; }
// Assertion function: narrows for the rest of the scope, throws otherwise
function assertNonNull<T>(v: T | null | undefined, msg?: string): asserts v is T {
if (v == null) throw new Error(msg ?? "value is null/undefined");
}
```
⚠ An explicit `x is T` predicate is **trusted, not verified** by the compiler — a wrong body is as unsafe as `as`. Prefer letting TS *infer* the predicate (TS 5.5+, see Gotchas); reserve explicit `is` for what inference can't express, and unit-test those.
## tsconfig & Strict Mode
`module`/`moduleResolution` must match where the output runs — there is no universal template. Pick ONE of the two below; never copy a `nodenext` config into a bundler app or vice versa.
### Node app or published library — `module: nodenext`
Requires explicit `.js` extensions on relative imports plus `"type": "module"` in `package.json` (or `.mts`/`.cts`). `nodenext` implies a matching `lib`/`target`, so an explicit `"lib"` is redundant here.
```json
{
"compilerOptions": {
"module": "nodenext",
"verbatimModuleSyntax": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
```
### Bundler app (Vite / Rolldown)
Extensionless relative imports work; the bundler emits, so `tsc` only type-checks (`noEmit`). ⚠ Never use `moduleResolution: bundler` for a **published library** — it's "infectious": emitted `.d.ts` files carry extensionless relative imports that break Node.js ESM consumers. Vite transpiles TS with Oxc, which strips types without checking them — `tsc --noEmit` stays the type gate (`oxlint --type-aware` covers the lint half, not the compiler's).
```json
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"]
}
```
### Flags beyond `strict`
`strict: true` does NOT include these — opt in explicitly (TS 5.9 `tsc --init` now enables them for new projects):
- **`noUncheckedIndexedAccess`** — adds `| undefined` to array subscripts and index-signature access (`arr[i]: T | undefined`). ⚠ NOT applied to named properties, NOT to `for...of` loop variables (by design, microsoft/TypeScript#42622), NOT to `Object.values()` — so it's no safety net when iterating.
- **`exactOptionalPropertyTypes`** — `obj.x = undefined` becomes an error for `x?: "a" | "b"`; only *deleting* the key makes the property absent. Matters for `"x" in obj` checks and serialization round-trips.
- **`useUnknownInCatchVariables`** (on via `strict` since 4.4) — `catch` vars are `unknown`; guard with `instanceof Error`.
- **`verbatimModuleSyntax`** — modern module-safety baseline; supersedes the now-no-op `importsNotUsedAsValues`/`preserveValueImports` (see Modules).
- **`isolatedModules`** — forbids constructs single-file transpilers can't handle (re-exporting a type without `type`, `const enum` inlining); required for single-file transpilers — Oxc (Vite, tsdown), Bun's transpiler, esbuild, swc, Babel.
### Checklist: tsconfig
- [ ] Exactly one of `nodenext` / `bundler` chosen to match the runtime — not mixed
- [ ] `strict: true` PLUS `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` (they aren't in `strict`)
- [ ] `verbatimModuleSyntax: true`; type-only imports carry the `type` modifier
- [ ] Library build: `declaration: true`, NOT `moduleResolution: bundler`, no `const enum` in public `.d.ts`
- [ ] `isolatedModules: true` if any non-`tsc` transpiler is in the build
## Toolchain: Bun + Vite + Oxc
One stack, no overlap: **Bun** installs, runs scripts, and runs `bun test`; **Vite** builds apps (Rolldown bundler, Oxc transform + minifier, Lightning CSS); **tsdown** builds libraries (Rolldown); **oxlint** lints; **oxfmt** formats; `tsc --noEmit` type-checks. Oxc replaces ESLint, Prettier, esbuild, and Babel — never add those to a project that has this stack, and never run two formatters over the same files; a second linter is acceptable only as a temporary migration bridge (see oxlint below).
```bash
bun add -D typescript oxlint oxfmt
bunx oxlint --init # writes .oxlintrc.json
bunx oxfmt --init # writes .oxfmtrc.json (--migrate=prettier converts an existing Prettier config)
bun add -D oxlint-tsgolint # needed by "typeAware": true in the config below; drop both to skip type-aware rules
```
`package.json` scripts:
```json
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "oxlint --deny-warnings",
"lint:fix": "oxlint --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"check": "bun run typecheck && bun run lint && bun run format:check"
}
}
```
### oxlint
- Config is `.oxlintrc.json`. Rules are grouped in categories; only `correctness` is on by default — opt into `suspicious`, `perf`, `pedantic` per project rather than `all`.
- Setting `plugins` REPLACES the default set (`eslint`, `typescript`, `unicorn`, `oxc`) — list the defaults you keep alongside the ones you add (`import`, `promise`, `vitest`, `react`, `jsx-a11y`, ...).
- `oxlint --fix` applies only safe fixes; `--fix-suggestions` and `--fix-dangerously` can change behavior — review the diff.
- Type-aware rules (`typescript/no-floating-promises`, `typescript/no-unsafe-assignment`, ...) need `oxlint-tsgolint` plus `"options": { "typeAware": true }` (or `oxlint --type-aware`). They run on the native TypeScript compiler (tsgo), so the tsconfig must be tsgo-clean — no `baseUrl` — and `include` should stay tight or the pass gets slow.
- Migrating from ESLint: `bunx -p @oxlint/migrate oxlint-migrate` converts a flat config to `.oxlintrc.json`. If a rule oxlint lacks must stay, keep ESLint for that rule only, add `eslint-plugin-oxlint` to turn off everything oxlint already covers, and run `oxlint && eslint` so the fast pass fails first.
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "import", "promise"],
"categories": { "correctness": "error", "suspicious": "warn" },
"rules": {
"typescript/no-explicit-any": "error",
View on GitHub