TypeScript Development
This skill covers modern TypeScript best practices for writing clean, type-safe code. It focuses on the language itself — no library or framework specifics. Apply these conventions to all TypeScript code in the project.
Strict Mode
Always enable strict: true in tsconfig.json. Never disable individual strict flags. This is non-negotiable — it catches entire categories of bugs at compile time.
Key strict behaviors:
nullandundefinedare distinct types (no implicit null)- Every value must have a known type (no implicit
any) - Catch clause variables are
unknown, notany
Naming Conventions
| Construct | Convention | Example |
|---|---|---|
| Variables, functions | camelCase | getUserName, isActive |
| Classes | PascalCase | UserService, HttpClient |
| Interfaces | PascalCase (no I prefix) |
User, not IUser |
| Type aliases | PascalCase | ApiResponse, EventMap |
| Constants | camelCase or UPPER_SNAKE | maxRetries or MAX_RETRIES |
| Enum-like objects | PascalCase key, camelCase/string values | Status.Active |
| Generic parameters | Single uppercase or descriptive | T, TResult, K extends keyof T |
| File names | kebab-case | user-service.ts, api-client.ts |
| Boolean variables | Prefix with is, has, can, should |
isValid, hasPermission |
Type Annotations
When to annotate explicitly
- Function parameters — always
- Function return types — always for exported functions, optional for local functions
- Class properties — always
- Variables — only when the type cannot be inferred
// Parameters and return: always annotate
function calculateTotal(items: LineItem[], taxRate: number): number {
return items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}
// Variable: skip annotation when inferred
const total = calculateTotal(items, 0.1); // inferred as number
// Variable: annotate when not obvious
const cache: Map<string, User> = new Map();
Prefer interfaces for object shapes
// Prefer interface for object shapes
interface User {
id: string;
name: string;
email: string;
}
// Use type alias for unions, intersections, mapped types
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
type StringKeys<T> = Extract<keyof T, string>;
Avoid any
Use unknown instead of any for values of uncertain type. Narrow with type guards before use:
// Bad
function parse(input: any): string { return input.name; }
// Good
function parse(input: unknown): string {
if (typeof input === "object" && input !== null && "name" in input) {
return String((input as { name: unknown }).name);
}
throw new Error("Invalid input");
}
Acceptable uses of any: Only when interfacing with untyped external code and a proper type cannot be defined. Always add a comment explaining why.
Discriminated Unions
Model state variants with a shared literal discriminant:
type LoadingState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
Always include an exhaustive check using never:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function render<T>(state: LoadingState<T>): string {
switch (state.status) {
case "idle": return "Ready";
case "loading": return "Loading...";
case "success": return String(state.data);
case "error": return state.error.message;
default: return assertNever(state);
}
}
Error Handling
Catch unknown errors
try {
await riskyOperation();
} catch (error: unknown) {
if (error instanceof AppError) {
handleAppError(error);
} else if (error instanceof Error) {
handleGenericError(error);
} else {
handleUnknown(String(error));
}
}
Custom error classes
class AppError extends Error {
constructor(
message: string,
readonly code: string,
readonly statusCode: number = 500,
) {
super(message);
this.name = "AppError";
}
}
Result type over exceptions
For expected failure paths, prefer a typed Result over throwing:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Const Objects Over Enums
Prefer as const objects over TypeScript enum:
const Status = {
Active: "active",
Inactive: "inactive",
Pending: "pending",
} as const;
type Status = (typeof Status)[keyof typeof Status];
Why: No runtime code emitted, better tree-shaking, interoperates with plain strings.
Async Code
- Always specify
Promise<T>return types on async functions - Use
Promise.allfor independent concurrent operations - Use
Promise.allSettledwhen partial failure is acceptable - Never use
voidfor async function returns — usePromise<void>
async function fetchUserData(id: string): Promise<UserData> {
const [profile, orders] = await Promise.all([
fetchProfile(id),
fetchOrders(id),
]);
return { profile, orders };
}
Immutability
- Use
readonlyon properties that should not change after initialization - Use
readonly T[](orReadonlyArray<T>) for array parameters that should not be mutated - Use
as constfor literal objects and arrays that should be fully immutable - Prefer spreading over mutation:
{ ...obj, key: newValue }overobj.key = newValue
Type-Only Imports
Use import type for imports used only as types:
import type { User } from "./models.js";
import { createUser } from "./models.js";
This prevents circular dependency issues and ensures types are erased at compile time.
ESM Import Rule
When using "type": "module" in package.json with "module": "Node16", all relative imports must include the .js extension — even in .ts source files:
import { helper } from "./utils.js"; // Correct
import { helper } from "./utils"; // Wrong — fails at runtime
Quick Reference: Common Mistakes
| Mistake | Fix |
|---|---|
Using any |
Use unknown and narrow with type guards |
| Missing return type on exports | Add explicit return type annotation |
enum for string constants |
Use as const object + derived union type |
| Mutable function parameters | Mark arrays/objects as readonly |
Bare catch (error) |
Use catch (error: unknown) and narrow |
Missing .js in ESM imports |
Add .js extension to all relative imports |
strict: false in tsconfig |
Always use strict: true |
I prefix on interfaces |
Drop the prefix: User, not IUser |
| Optional props for distinct states | Use discriminated unions |
Type assertions (as T) |
Prefer type guards and narrowing |
Additional Resources
Reference Files
For detailed type system features and advanced patterns, consult:
references/type-system.md— Generics, utility types, conditional types, mapped types, template literal types, type guards, discriminated unions, branded types, satisfies operator, const assertions, declaration mergingreferences/patterns.md— Immutability patterns, error handling (Result type, custom errors), async patterns (generators, concurrency), builder pattern, type-safe event emitter, overloaded functions, module patterns, enum alternatives, assertion functions, narrowing patternsreferences/type-inference.md— Variable inference, function return inference, generic inference, contextual typing, satisfies operator, infer keyword, control flow analysis, type guards, narrowing patterns, best practices for when to annotate vs let inference workreferences/project-structure.md— tsconfig.json essentials (strict mode flags, module config, safety flags), ESM/CJS setup, directory layout, organizing types, barrel exports, declaration files, import organization, path aliases, gitignore