typescript-guide
SKILL.md
TypeScript/JavaScript Guide
Applies to: TypeScript 5+, Node.js 20+, ES2022+, React, Server-Side JS
Core Principles
- Strict TypeScript: Enable all strict flags; treat type errors as build failures
- Immutability by Default: Use
const,readonly,as const, and spread operators; mutate only when profiling demands it - Explicit Types at Boundaries: All function signatures, API responses, and public interfaces must have explicit type annotations; infer internally
- Functional Patterns: Prefer pure functions, map/filter/reduce, and composition over classes and mutation
- Zero
any: Useunknownfor truly unknown data, Zod/io-ts for runtime narrowing; everyanyrequires a code-review comment explaining why
Guardrails
TypeScript Configuration
- Enable
"strict": true(this activatesstrictNullChecks,noImplicitAny,strictFunctionTypes, etc.) - Enable
"noUncheckedIndexedAccess": true(arrays and records returnT | undefined) - Enable
"noImplicitReturns": trueand"noFallthroughCasesInSwitch": true - Set
"target": "ES2022","module": "ESNext","moduleResolution": "bundler" - Never suppress errors with
@ts-ignore; use@ts-expect-errorwith a justification comment
Code Style
constoverlet; never usevar- Nullish coalescing (
??) over logical OR (||) for defaults (avoids falsy-value bugs with0,"",false) - Optional chaining (
?.) over nested null checks - Template literals over string concatenation
- Destructuring at call sites:
const { id, name } = user - Barrel exports (
index.ts) only at package boundaries, not within internal modules (causes circular imports and tree-shaking failures) - Enums: prefer
as constobjects over TypeScriptenum(enums produce runtime code and have surprising behaviors with reverse mappings)
// Prefer this
const Status = {
Active: "active",
Inactive: "inactive",
} as const;
type Status = (typeof Status)[keyof typeof Status];
// Over this
enum Status {
Active = "active",
Inactive = "inactive",
}
Error Handling
- Every
catchblock must type-narrow the error before accessing properties - Never throw primitive values (strings, numbers); always throw
Errorsubclasses - Use the
causeoption for error chaining:new AppError("msg", { cause: original }) - Never swallow errors with empty
catchblocks - Async functions must have error handling at the call site or a global boundary handler
Async/Await
- Always
awaitor return promises; never create fire-and-forget promises without explicitvoidannotation - Use
Promise.allfor independent concurrent operations, not sequentialawaitin a loop - Set timeouts on all external calls (fetch, database, third-party APIs) using
AbortController - Use
Promise.allSettledwhen partial failure is acceptable - Never mix
.then()chains withawaitin the same function
// Bad: sequential when order does not matter
const users = await fetchUsers();
const posts = await fetchPosts();
// Good: concurrent
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]);
// Good: timeout with AbortController
async function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
Module System
- Use ES modules (
import/export) exclusively; norequire()in TypeScript - One export per concept: prefer named exports over default exports (improves refactoring, grep-ability)
- Side-effect imports (
import "./setup") must be documented with a comment explaining why - Re-export from
index.tsonly at package/feature boundaries - Keep import order: stdlib/node builtins, external packages, internal modules, relative paths (enforce with ESLint
import/order)
Project Structure
myproject/
├── src/
│ ├── index.ts # Application entry point
│ ├── config/ # Environment, feature flags
│ │ └── env.ts # Validated env vars (Zod schema)
│ ├── domain/ # Business logic (no I/O)
│ │ ├── user.ts
│ │ └── order.ts
│ ├── services/ # Application services (orchestrate domain + I/O)
│ ├── repositories/ # Data access (database, external APIs)
│ ├── routes/ # HTTP handlers / controllers
│ ├── middleware/ # Express/Koa/Hono middleware
│ ├── utils/ # Pure utility functions
│ └── types/ # Shared type definitions
├── tests/
│ ├── unit/ # Mirror src/ structure
│ ├── integration/ # API and database tests
│ └── fixtures/ # Shared test data
├── tsconfig.json
├── package.json
├── .eslintrc.cjs
├── .prettierrc
└── vitest.config.ts
- Domain logic in
domain/must have zero I/O dependencies (pure functions, easy to test) - No business logic in route handlers; delegate to services
- Shared types in
types/; co-locate component-specific types with their module
Error Handling Patterns
Custom Application Errors
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
options?: ErrorOptions
) {
super(message, options);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, "NOT_FOUND", 404);
this.name = "NotFoundError";
}
}
Result Pattern (for Expected Failures)
type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number> {
const age = Number(input);
if (Number.isNaN(age) || age < 0 || age > 150) {
return { ok: false, error: new AppError("Invalid age", "VALIDATION") };
}
return { ok: true, value: age };
}
// Usage: caller must check before accessing value
const result = parseAge(input);
if (!result.ok) {
return res.status(400).json({ error: result.error.message });
}
console.log(result.value); // Type-safe: number
Typed Catch Blocks
try {
await externalService.call();
} catch (error: unknown) {
if (error instanceof AppError) {
logger.warn(error.message, { code: error.code });
return res.status(error.statusCode).json({ error: error.message });
}
// Unknown error: wrap and re-throw
throw new AppError("Unexpected failure", "INTERNAL", 500, { cause: error });
}
Testing
Standards
- Test runner: Vitest (preferred) or Jest
- Test files: co-located as
*.test.tsor undertests/mirroringsrc/ - Naming:
describe("ModuleName")withit("should [expected behavior] when [condition]") - Use
beforeEachfor setup; avoidbeforeAllfor mutable state - Coverage target: >80% for business logic, >60% overall
- No
anyin test files; test helpers must be typed - Mock at boundaries (HTTP, database, file system), not internal functions
Table-Driven Tests
import { describe, it, expect } from "vitest";
import { validateEmail } from "./validate";
describe("validateEmail", () => {
const cases = [
{ input: "user@example.com", expected: true, desc: "valid email" },
{ input: "no-at-sign", expected: false, desc: "missing @ symbol" },
{ input: "", expected: false, desc: "empty string" },
{ input: "a@b.c", expected: true, desc: "minimal valid email" },
] as const;
it.each(cases)("returns $expected for $desc", ({ input, expected }) => {
expect(validateEmail(input)).toBe(expected);
});
});
Mocking External Dependencies
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserService } from "./user.service";
import type { UserRepository } from "./user.repository";
describe("UserService", () => {
let service: UserService;
let repo: UserRepository;
beforeEach(() => {
repo = {
findById: vi.fn(),
save: vi.fn(),
} as unknown as UserRepository;
service = new UserService(repo);
});
it("should throw NotFoundError when user does not exist", async () => {
vi.mocked(repo.findById).mockResolvedValue(null);
await expect(service.getUser("abc")).rejects.toThrow("not found");
expect(repo.findById).toHaveBeenCalledWith("abc");
});
});
Tooling
Essential Commands
tsc --noEmit # Type check (no output)
eslint . --ext .ts,.tsx # Lint
prettier --check . # Format check
prettier --write . # Format fix
vitest # Run tests (watch mode)
vitest run # Run tests (CI mode)
vitest run --coverage # With coverage
ESLint Configuration (Flat Config)
// eslint.config.mjs
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
export default tseslint.config(
...tseslint.configs.strictTypeChecked,
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": ["warn", {
allowExpressions: true,
}],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"import/order": ["error", {
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
},
}
);
Strict tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
Input Validation (Zod)
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
role: z.enum(["admin", "user", "viewer"]),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// At API boundary
function handleCreateUser(rawBody: unknown): CreateUserInput {
return CreateUserSchema.parse(rawBody); // throws ZodError on failure
}
Advanced Topics
For detailed patterns and examples, see:
- references/patterns.md -- Advanced type patterns, Zod validation, async orchestration, module organization, React + TypeScript idioms
- references/pitfalls.md -- Common TypeScript footguns, type narrowing mistakes, async traps
- references/security.md -- XSS prevention, CSRF, content security policy, dependency auditing
External References
Weekly Installs
5
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
14 days ago
Security Audits
Installed on
opencode5
gemini-cli5
codebuddy5
github-copilot5
codex5
kimi-cli5