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

UsePrefer
Object shapes, class contractsinterface
Unions, tuples, mapped typestype
Public API of a packageOne 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

text
  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.ts dumping grounds.

Explicit Module Boundaries

typescript
// 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

ItemConvention
Types, interfacesPascalCase
GenericsT, TItem, TResponse (descriptive in public APIs)
Boolean propertiesisActive, hasAccess, canEdit
Fileskebab-case or camelCase—pick one per repo

Prefer Discriminated Unions for State

typescript
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

typescript
/**
 * 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 any without comment and plan
  • strict still 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.