typescript
State-of-the-art TypeScript — strict compiler settings, Zod runtime validation, branded types, discriminated unions, and modern language features for fully type-safe codebases.
SKILL.mdskills/languages/typescript/SKILL.md
GitHubtypescript
State-of-the-art TypeScript coding standards.
Implements all mandatory rules from code-quality: full type safety, typed errors, immutability, and clean naming.
Instructions
Apply these rules to every TypeScript project regardless of framework (Node.js, React, Next.js, Bun, etc.).
Guidelines
Compiler — tsconfig.json
Always enable the full strict suite plus the extra safety flags:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"]
}
}
strict: trueenablesstrictNullChecks,noImplicitAny,strictFunctionTypes,strictBindCallApply,strictPropertyInitialization, anduseUnknownInCatchVariablesnoUncheckedIndexedAccessmakesarray[i]returnT | undefined— forces bounds checksexactOptionalPropertyTypesdistinguishes{ key?: string }from{ key: string | undefined }verbatimModuleSyntaxenforcesimport typefor type-only imports
Type Safety
No any — use unknown and narrow
// ❌ unsafe — bypasses the type checker
function parse(raw: any): User { return raw; }
// ✅ safe — forces narrowing before use
function parse(raw: unknown): User {
return userSchema.parse(raw); // Zod throws if invalid
}
Zod for every external boundary
Validate all data that crosses a trust boundary (HTTP request body, API response, env vars, file content, query params):
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(['admin', 'member', 'guest']),
});
type CreateUserDto = z.infer<typeof CreateUserSchema>;
// In a route handler
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({ error: result.error.format() });
}
const dto: CreateUserDto = result.data; // fully typed, validated
Validated environment variables
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']),
JWT_SECRET: z.string().min(32),
});
export const env = EnvSchema.parse(process.env);
// Use env.DATABASE_URL, env.PORT — fully typed everywhere
Branded / Nominal types
Prevent mixing semantically different string or number values:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
const toUserId = (id: string): UserId => id as UserId;
const toOrderId = (id: string): OrderId => id as OrderId;
function getUser(id: UserId): Promise<User> { /* … */ }
// ❌ compile error — prevents accidental ID confusion
getUser(toOrderId('ord_123'));
// ✅
getUser(toUserId('usr_456'));
Discriminated unions over optional fields
// ❌ ambiguous — callers must check multiple optionals
type ApiResult = { data?: User; error?: string; loading?: boolean };
// ✅ exhaustively checkable — exactly one state at a time
type ApiResult<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string };
function render(result: ApiResult<User>) {
switch (result.status) {
case 'loading': return <Spinner />;
case 'success': return <Profile user={result.data} />;
case 'error': return <Alert msg={result.message} />;
// TypeScript exhaustiveness check — no default needed
}
}
satisfies operator for literal inference
// ❌ widened to Record<string, string> — loses key information
const routes = { home: '/', about: '/about' };
// ✅ validates shape AND preserves literal types
const routes = {
home: '/',
about: '/about',
blog: '/blog',
} satisfies Record<string, `/${string}`>;
type RouteName = keyof typeof routes; // 'home' | 'about' | 'blog'
const type parameters (TypeScript 5.0+)
// Without const — T inferred as string[]
function identity<T>(arr: T[]): T[] { return arr; }
// With const — T inferred as readonly ['a', 'b']
function identity<const T extends readonly unknown[]>(arr: T): T { return arr; }
const result = identity(['a', 'b']); // type: readonly ['a', 'b']
Error Handling
Typed error classes
// ❌ bare Error — caller can't distinguish error types
throw new Error('User not found');
// ✅ domain error — typed, catchable, loggable
class UserNotFoundError extends Error {
readonly code = 'USER_NOT_FOUND' as const;
constructor(readonly userId: string) {
super(`User not found: ${userId}`);
this.name = 'UserNotFoundError';
}
}
// In catch blocks (strict mode makes `e` unknown)
try {
await userService.get(id);
} catch (e) {
if (e instanceof UserNotFoundError) {
return res.status(404).json({ error: e.code, userId: e.userId });
}
throw e; // re-throw unexpected errors
}
Result type for recoverable errors
Use a Result<T, E> pattern to make errors part of the return type instead of exceptions for expected failure paths:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });
async function findUser(id: UserId): Promise<Result<User, UserNotFoundError>> {
const user = await db.users.findUnique({ where: { id } });
return user ? ok(user) : err(new UserNotFoundError(id));
}
// Caller is forced to handle both paths
const result = await findUser(id);
if (!result.ok) return res.status(404).json({ error: result.error.code });
res.json(result.value);
Immutability
// Prefer const at every level
const MAX_RETRIES = 3;
// Readonly object shapes
type Config = Readonly<{
apiUrl: string;
timeout: number;
}>;
// Readonly arrays
function sort(items: readonly string[]): string[] {
return [...items].sort(); // clone before mutating
}
// as const for literal objects / tuples
const ROLES = ['admin', 'member', 'guest'] as const;
type Role = typeof ROLES[number]; // 'admin' | 'member' | 'guest'
Naming Conventions
| Entity | Convention | Example |
|---|---|---|
| Variable / function | camelCase |
getUserByEmail |
| Class / Interface / Type / Enum | PascalCase |
UserService, CreateUserDto |
| File | kebab-case |
user-service.ts |
| Constant (module-level) | SCREAMING_SNAKE_CASE |
MAX_PAGE_SIZE |
| Boolean | is/has/can prefix |
isAuthenticated, hasPermission |
| Zod schema | PascalCase + Schema suffix |
CreateUserSchema |
| Type from Zod | PascalCase + Dto/Input suffix |
CreateUserDto |
- No
Iprefix on interfaces (UserServicenotIUserService) - No
Tprefix on generic type params beyond single-letter generics - Use
import typefor type-only imports (enforced byverbatimModuleSyntax)
Code Organisation
src/
domain/ ← pure business logic; no HTTP/DB imports
user/
user.ts ← entity + value objects
user.errors.ts
user.service.ts
infra/ ← adapters: DB, HTTP clients, queues
db/
http/
api/ ← HTTP layer: routes, middleware, schemas
lib/ ← shared utilities (result, logger, env)
index.ts
domain/must not import frominfra/orapi/— enforce with ESLintimport/no-restricted-paths- One file per class/module; keep files ≤ 300 lines
- Re-export from
index.tsbarrel files only for public APIs; avoid deep barrel chains inside a module
Documentation
/**
* Finds a user by their email address.
*
* @param email - Must be a valid, normalised email address.
* @returns The matching user or `null` if not found.
* @throws {DatabaseError} When the DB connection fails.
*/
async function findUserByEmail(email: string): Promise<User | null> { /* … */ }
- All public functions, classes, and types must have TSDoc comments
- Use
@param,@returns,@throwstags - No commented-out code in committed files
Tooling Baseline
| Tool | Purpose | Config |
|---|---|---|
TypeScript 5.x |
Compiler | tsconfig.json with strict flags above |
ESLint + typescript-eslint |
Linting | recommended-type-checked ruleset |
| Prettier | Formatting | .prettierrc |
| Zod | Runtime validation | Schema files co-located with domain |
| Vitest | Unit / integration tests | vitest.config.ts |
Examples
Fully typed service with Result pattern
import { z } from 'zod';
// --- Schema & types ---
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
authorId: z.string().uuid(),
});
type CreatePostDto = z.infer<typeof CreatePostSchema>;
// --- Domain errors ---
class AuthorNotFoundError extends Error {
readonly code = 'AUTHOR_NOT_FOUND' as const;
constructor(readonly authorId: string) {
super(`Author not found: ${authorId}`);
this.name = 'AuthorNotFoundError';
}
}
// --- Service ---
class PostService {
constructor(
private readonly postRepo: PostRepository,
private readonly userRepo: UserRepository,
) {}
async create(dto: CreatePostDto): Promise<Result<Post, AuthorNotFoundError>> {
const author = await this.userRepo.findById(dto.authorId);
if (!author) return err(new AuthorNotFoundError(dto.authorId));
const post = await this.postRepo.create({
title: dto.title,
content: dto.content,
authorId: dto.authorId,
});
return ok(post);
}
}
// --- Controller ---
app.post('/posts', async (req, res) => {
const parsed = CreatePostSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ error: parsed.error.format() });
}
const result = await postService.create(parsed.data);
if (!result.ok) {
return res.status(404).json({ error: result.error.code });
}
res.status(201).json(result.value);
});