TypeScript Code Style and Architecture
Introduction
TypeScript rewards consistency: clear module boundaries, minimal any, and types that model your domain. This chapter collects style and architecture practices that keep medium and large codebases readable as they grow—without over-engineering small scripts.
Prerequisites
interface vs type: Team Rules
| Use | Prefer |
|---|---|
| Object shapes, class contracts | interface |
| Unions, tuples, mapped types | type |
| Public API of a package | One style per exported concept—document it |
Avoid duplicating the same name as both interface User and type User.
Avoid any; Use unknown and Guards
Validate at boundaries; trust types inside the core.
Layer Your Types
domain/ ← entities and business rules
services/ ← orchestration
api/ ← HTTP handlers, DTO mapping
infra/ ← DB, filesystem, external APIs- Domain types should not import framework types.
- API layer maps external JSON to domain types (narrow
unknown). - Share only what other layers need—avoid giant
types.tsdumping grounds.
Explicit Module Boundaries
// user.service.ts
import type { User } from "./user.types.js";
export function getUser(id: string): Promise<User> {
// ...
}Use import type for type-only imports when verbatimModuleSyntax is enabled.
Naming Conventions
| Item | Convention |
|---|---|
| Types, interfaces | PascalCase |
| Generics | T, TItem, TResponse (descriptive in public APIs) |
| Boolean properties | isActive, hasAccess, canEdit |
| Files | kebab-case or camelCase—pick one per repo |
Prefer Discriminated Unions for State
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };Safer than optional fields scattered across one object.
Do Not Over-Abstract Early
Skip premature:
- Generic utility types copied from blog posts
- Base classes for two implementations
any“temporary” bridges without tickets to remove them
Add structure when you have two real call sites that share logic.
ESLint and Prettier
Typical stack:
- Prettier — formatting
- @typescript-eslint — unused vars, consistent type imports, ban certain patterns
Align ESLint TypeScript version with your typescript package.
Documentation in Types
/**
* Charges the customer in cents (integer).
*/
export function charge(amountCents: number): Promise<Receipt> {
// ...
}JSDoc on exported functions helps consumers; types carry most contracts.
Code Review Checklist
- No new
anywithout comment and plan -
strictstill enabled - External data narrowed from
unknown - Exported APIs have explicit parameter/return types
- Tests or typecheck pass in CI
FAQ
Enforce style how?
ESLint + Prettier + CI tsc --noEmit.
Monorepo tips?
Shared tsconfig.base.json, project references, consistent package boundaries.
Barrel files (index.ts)?
Convenient imports; can hurt tree-shaking—use intentionally.
Branded types for IDs?
type UserId = string & { readonly __brand: unique symbol }—advanced pattern for mixed ID strings.
Architecture for small CLI?
Single src/ folder is fine until pain appears—then split by feature.