TypeScript/JS expert for type-level programming, performance optimization, monorepo management, migration strategies, modern tooling -- use PROACTIVELY
| Signal | Route to | Focus |
|---|---|---|
| Bundler internals, tree-shaking, chunk splitting | build-expert | Vite/esbuild/Rollup/webpack config |
ESM/CJS interop, require vs import, dual-package | module-expert | Module resolution, package.json exports |
| Generic constraints, mapped types, conditional types | type-expert | Type-level programming, utility types |
| Setup, migration, debugging, tooling | this skill | Full coverage below |
Address cross-domain questions in order: build -> module -> types (each layer constrains the next).
Detect before advising: (1) TS version, (2) module/moduleResolution in tsconfig, (3) strict flag state, (4) build tool (vite/esbuild/tsup/tsc), (5) monorepo tool (pnpm workspaces/nx/turbo), (6) linter (biome vs eslint), (7) test framework, (8) runtime + version, (9) framework. Adapt all recommendations to the detected stack.
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
type UserId = Brand<string, "UserId">;
type ProjectId = Brand<string, "ProjectId">;
function userId(raw: string): UserId {
if (!raw.match(/^usr_[a-z0-9]{12}$/)) throw new Error(`Invalid UserId: ${raw}`);
return raw as UserId;
}
// Compiler catches: getProject(userId("...")) -- UserId not assignable to ProjectId
Use when: IDs cross function boundaries, currency/unit confusion is possible, or API boundaries need compile-time safety.
// DeepReadonly -- recursive freeze
type DeepReadonly<T> =
T extends readonly (infer U)[] ? readonly DeepReadonly<U>[]
: T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>>
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// Extract path params from route strings
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}` ? Param
: never;
// ExtractParams<"/users/:userId/posts/:postId"> = "userId" | "postId"
// satisfies -- validate shape without widening
const config = { port: 3000, host: "localhost" } satisfies Record<string, unknown>;
// config.port is number (preserved), not unknown
// const assertions -- narrow to literals
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
// Key remapping in mapped types
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
Diagnose with tsc --extendedDiagnostics. Watch Check time (complex types), I/O time (too many files), Bind time (too many declarations).
Fixes ranked by impact:
| Fix | Impact | Effort |
|---|---|---|
skipLibCheck: true | High | Trivial |
incremental: true | High | Trivial |
| Replace deep conditional types with interfaces | High | Medium |
| Project references for monorepos | High | Medium |
| Explicit return types on exported functions | Medium | Medium |
Use Pick instead of Omit on large unions | Medium | Low |
| Remove barrel files (index.ts re-exports) | Medium | Low |
Narrow include globs | Low-Medium | Trivial |
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"skipLibCheck": true,
"isolatedModules": true,
"composite": true, // required for project references
"declaration": true,
"declarationMap": true
}
}
Build monorepos with tsc --build (respects project reference graph, parallel where possible).
Cause: return type references an unexported internal type from a dependency. Fix: add an explicit return type annotation using a public type, or declare your own interface matching the needed shape.
Create src/types/vendor.d.ts with declare module "legacy-lib" { ... }. Extend existing modules via declaration merging. Ensure the file is included via tsconfig include or typeRoots.
Add a recursion depth counter via a tuple type parameter:
type DeepFlatten<T, Depth extends number[] = []> =
Depth["length"] extends 10 ? T // bail at depth 10
: T extends Array<infer U> ? DeepFlatten<U, [...Depth, 0]>
: T;
tsc --traceResolution 2>&1 | grep "FAILED\|resolved"
Common fixes: switch moduleResolution to "nodenext" or "bundler", add .js extensions in ESM imports, verify paths aliases have matching runtime resolvers.
tsconfig paths only affects type checking. Runtime solutions by tool:
| Tool | Solution |
|---|---|
| tsc only | tsc-alias post-build or tsconfig-paths/register |
| Vite | vite-tsconfig-paths plugin |
| esbuild/tsup | Built-in alias option |
| Node.js ESM | package.json "imports" field (native, no tooling) |
Phase 1 (1 day): Install TS, strict: false, allowJs: true, rename entry files to .ts, add @ts-check to remaining JS.
Phase 2 (1-2 weeks): Convert leaf-first (utils -> services -> entry points), add .d.ts for untyped deps, enable noImplicitAny then strictNullChecks.
Phase 3 (ongoing): Enable strict: true, remove allowJs, replace all any with proper types, add branded types.
| From -> To | Effort | When worth it |
|---|---|---|
| ESLint+Prettier -> Biome | Low | New projects always; existing if config is simple |
| Jest -> Vitest | Medium | Vite-based or ESM-first codebases |
| Webpack -> Vite | High | New features stalled by config complexity |
| tsc emit -> tsup/esbuild | Low | Library publishing, need CJS+ESM dual output |
| CJS -> ESM | Medium-High | All deps support ESM or using bundler resolution |
| npm/yarn -> pnpm | Low | Monorepos, strict hoisting needed |
| Criterion | Nx | Turborepo |
|---|---|---|
| Setup complexity | Higher | Lower (just turbo.json) |
| Task orchestration | Advanced (affected graph) | Good (pipeline + cache) |
| Code generation | Built-in generators | None |
| Language support | Polyglot | JS/TS only |
| Best for | Large teams, polyglot repos | Small-medium TS monorepos |
Project references: every referenced package needs composite: true + declaration: true. Import via package name (not cross-boundary relative paths). Configure workspace resolution in your package manager.
Choose Biome for speed and simpler config (includes formatting + import sorting). Choose ESLint when you need type-aware lint rules or custom plugins.
// sum.test-d.ts
import { expectTypeOf, test } from "vitest";
import { createUser } from "./user.js";
test("createUser returns correct shape", () => {
expectTypeOf(createUser).returns.toMatchTypeOf<{ id: string; name: string }>();
});
Run with vitest typecheck or vitest --typecheck.
tsc --showConfig # effective merged config
tsc --listFiles # files in compilation
tsc --noEmit # check errors without emitting
tsc --declaration --emitDeclarationOnly # generate .d.ts only
const ERROR_CODES = { VALIDATION_FAILED: "VALIDATION_FAILED", NOT_FOUND: "NOT_FOUND" } as const;
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
class AppError extends Error {
constructor(
readonly code: ErrorCode,
message: string,
readonly statusCode: number = 500,
readonly context?: Record<string, unknown>,
) {
super(message);
this.name = "AppError";
}
}
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"verbatimModuleSyntax": true,
"target": "ES2022",
"lib": ["ES2023"],
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"incremental": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
.js extensions in imports with nodenext resolution (even for .ts source)"type": "module" in package.jsonimport type for type-only imports (verbatimModuleSyntax enforces this)node: protocol for built-ins (import { readFile } from "node:fs/promises")tsup with format: ["cjs", "esm"] and exports mapType Safety: No any (use unknown + type guards) | No ! without justification | No as X where a guard works | Discriminated unions over optional properties | Index access protected by noUncheckedIndexedAccess
Patterns: satisfies over as const when validation + inference both needed | Typed error classes | Branded types for cross-boundary IDs | No const enum (incompatible with isolatedModules) | No namespace in app code
Performance: No barrel re-exports | Explicit return types on exports | Conditional types max 3-4 nesting levels | Narrow generic constraints
Modules: import type for type-only | No circular deps | Dynamic import() for heavy optional deps | exports field configured for libraries
Errors: catch(e: unknown) and narrow | No unhandled promise rejections | Error messages include context (which ID, which op)
Organization: Files under 300 lines | One type per concern | Shared utility types in types/ directory
Library -> tsup (simple) or Rollup (advanced tree-shaking). Web app -> Vite. Backend service -> tsc (simple) or esbuild (fast). CLI tool -> tsup with target: "node22".
tsc --extendedDiagnosticsOmit on large unionsinclude, enable skipLibCheck, split test tsconfigincremental: true