Files
bootstrap/guides/TYPESCRIPT.md
2026-02-21 09:55:34 -06:00

11 KiB

TypeScript Style Guide

Authority: This guide is MANDATORY for all TypeScript code. No exceptions without explicit approval.

Based on Google TypeScript Style Guide with stricter enforcement.


Core Principles

  1. Explicit over implicit — Always declare types, never rely on inference for public APIs
  2. Specific over generic — Use the narrowest type that works
  3. Safe over convenient — Type safety is not negotiable
  4. Contract-first boundaries — Cross-module and API payloads MUST use dedicated DTO files

DTO Contract (MANDATORY)

DTO files are REQUIRED for TypeScript module boundaries to preserve shared context and consistency.

Hard requirements:

  1. Input and output payloads crossing module boundaries MUST be defined in *.dto.ts files.
  2. Controller/service boundary payloads MUST use DTO types; inline object literal types are NOT allowed.
  3. Public API request/response contracts MUST use DTO files and remain stable across modules.
  4. Shared DTOs used by multiple modules MUST live in a shared location (for example src/shared/dto/ or packages/shared/dto/).
  5. ORM/entity models MUST NOT be exposed directly across module boundaries; map them to DTOs.
  6. DTO changes MUST be reflected in tests and documentation when contracts change.
// ❌ WRONG: inline payload contract at boundary
export function createUser(payload: { email: string; role: string }): Promise<User> { }

// ✅ CORRECT: dedicated DTO file contract
// user-create.dto.ts
export interface UserCreateDto {
  email: string;
  role: UserRole;
}

// user-response.dto.ts
export interface UserResponseDto {
  id: string;
  email: string;
  role: UserRole;
}

// service.ts
export function createUser(payload: UserCreateDto): Promise<UserResponseDto> { }

Forbidden Patterns (NEVER USE)

any Type — FORBIDDEN

// ❌ NEVER
function process(data: any) { }
const result: any = fetchData();
Record<string, any>

// ✅ ALWAYS define explicit types
interface UserData {
  id: string;
  name: string;
  email: string;
}
function process(data: UserData) { }

unknown as Lazy Typing — FORBIDDEN

unknown is only acceptable in these specific cases:

  1. Error catch blocks (then immediately narrow)
  2. JSON.parse results (then validate with Zod/schema)
  3. External API responses before validation
// ❌ NEVER - using unknown to avoid typing
function getData(): unknown { }
const config: Record<string, unknown> = {};

// ✅ ACCEPTABLE - error handling with immediate narrowing
try {
  riskyOperation();
} catch (error: unknown) {
  if (error instanceof Error) {
    logger.error(error.message);
  } else {
    logger.error('Unknown error', { error: String(error) });
  }
}

// ✅ ACCEPTABLE - external data with validation
const raw: unknown = JSON.parse(response);
const validated = UserSchema.parse(raw); // Zod validation

Implicit any — FORBIDDEN

// ❌ NEVER - implicit any from missing types
function process(data) { }  // Parameter has implicit any
const handler = (e) => { }  // Parameter has implicit any

// ✅ ALWAYS - explicit types
function process(data: RequestPayload): ProcessedResult { }
const handler = (e: React.MouseEvent<HTMLButtonElement>): void => { }

Type Assertions to Bypass Safety — FORBIDDEN

// ❌ NEVER - lying to the compiler
const user = data as User;
const element = document.getElementById('app') as HTMLDivElement;

// ✅ USE - type guards and narrowing
function isUser(data: unknown): data is User {
  return typeof data === 'object' && data !== null && 'id' in data;
}
if (isUser(data)) {
  console.log(data.id); // Safe
}

// ✅ USE - null checks
const element = document.getElementById('app');
if (element instanceof HTMLDivElement) {
  element.style.display = 'none'; // Safe
}

Non-null Assertion (!) — FORBIDDEN (except tests)

// ❌ NEVER in production code
const name = user!.name;
const element = document.getElementById('app')!;

// ✅ USE - proper null handling
const name = user?.name ?? 'Anonymous';
const element = document.getElementById('app');
if (element) {
  // Safe to use element
}

Required Patterns

Explicit Return Types — REQUIRED for all public functions

// ❌ WRONG - missing return type
export function calculateTotal(items: Item[]) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// ✅ CORRECT - explicit return type
export function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

Explicit Parameter Types — REQUIRED always

// ❌ WRONG
const multiply = (a, b) => a * b;
users.map(user => user.name);  // If user type isn't inferred

// ✅ CORRECT
const multiply = (a: number, b: number): number => a * b;
users.map((user: User): string => user.name);

Interface Over Type Alias — PREFERRED for objects

// ✅ PREFERRED - interface (extendable, better error messages)
interface User {
  id: string;
  name: string;
  email: string;
}

// ✅ ACCEPTABLE - type alias for unions, intersections, primitives
type Status = 'active' | 'inactive' | 'pending';
type ID = string | number;

Const Assertions for Literals — REQUIRED

// ❌ WRONG - loses literal types
const config = {
  endpoint: '/api/users',
  method: 'GET',
};
// config.method is string, not 'GET'

// ✅ CORRECT - preserves literal types
const config = {
  endpoint: '/api/users',
  method: 'GET',
} as const;
// config.method is 'GET'

Discriminated Unions — REQUIRED for variants

// ❌ WRONG - optional properties for variants
interface ApiResponse {
  success: boolean;
  data?: User;
  error?: string;
}

// ✅ CORRECT - discriminated union
interface SuccessResponse {
  success: true;
  data: User;
}
interface ErrorResponse {
  success: false;
  error: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;

Generic Constraints

Meaningful Constraints — REQUIRED

// ❌ WRONG - unconstrained generic
function merge<T>(a: T, b: T): T { }

// ✅ CORRECT - constrained generic
function merge<T extends object>(a: T, b: Partial<T>): T { }

Default Generic Parameters — USE SPECIFIC TYPES

// ❌ WRONG
interface Repository<T = unknown> { }

// ✅ CORRECT - no default if type should be explicit
interface Repository<T extends Entity> { }

// ✅ ACCEPTABLE - meaningful default
interface Cache<T extends Serializable = JsonValue> { }

React/JSX Specific

Event Handlers — EXPLICIT TYPES REQUIRED

// ❌ WRONG
const handleClick = (e) => { };
const handleChange = (e) => { };

// ✅ CORRECT
const handleClick = (e: React.MouseEvent<HTMLButtonElement>): void => { };
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { };
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => { };

Component Props — INTERFACE REQUIRED

// ❌ WRONG - inline types
function Button({ label, onClick }: { label: string; onClick: () => void }) { }

// ✅ CORRECT - named interface
interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

function Button({ label, onClick, disabled = false }: ButtonProps): JSX.Element {
  return <button onClick={onClick} disabled={disabled}>{label}</button>;
}

Children Prop — USE React.ReactNode

interface LayoutProps {
  children: React.ReactNode;
  sidebar?: React.ReactNode;
}

API Response Typing

Define Explicit Response Types

// ❌ WRONG
const response = await fetch('/api/users');
const data = await response.json(); // data is any

// ✅ CORRECT
interface UsersResponse {
  users: User[];
  pagination: PaginationInfo;
}

const response = await fetch('/api/users');
const data: UsersResponse = await response.json();

// ✅ BEST - with runtime validation
const response = await fetch('/api/users');
const raw = await response.json();
const data = UsersResponseSchema.parse(raw); // Zod validates at runtime

Error Handling

Typed Error Classes — REQUIRED for domain errors

class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly code: string
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NotFoundError extends Error {
  constructor(
    public readonly resource: string,
    public readonly id: string
  ) {
    super(`${resource} with id ${id} not found`);
    this.name = 'NotFoundError';
  }
}

Error Narrowing — REQUIRED

try {
  await saveUser(user);
} catch (error: unknown) {
  if (error instanceof ValidationError) {
    return { error: error.message, field: error.field };
  }
  if (error instanceof NotFoundError) {
    return { error: 'Not found', resource: error.resource };
  }
  if (error instanceof Error) {
    logger.error('Unexpected error', { message: error.message, stack: error.stack });
    return { error: 'Internal error' };
  }
  logger.error('Unknown error type', { error: String(error) });
  return { error: 'Internal error' };
}

ESLint Rules — ENFORCE THESE

{
  "@typescript-eslint/no-explicit-any": "error",
  "@typescript-eslint/explicit-function-return-type": ["error", {
    "allowExpressions": true,
    "allowTypedFunctionExpressions": true
  }],
  "@typescript-eslint/explicit-module-boundary-types": "error",
  "@typescript-eslint/no-inferrable-types": "off", // Allow explicit primitives
  "@typescript-eslint/no-non-null-assertion": "error",
  "@typescript-eslint/strict-boolean-expressions": "error",
  "@typescript-eslint/no-unsafe-assignment": "error",
  "@typescript-eslint/no-unsafe-member-access": "error",
  "@typescript-eslint/no-unsafe-call": "error",
  "@typescript-eslint/no-unsafe-return": "error"
}

TSConfig Strict Mode — REQUIRED

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true
  }
}

Summary: The Type Safety Hierarchy

From best to worst:

  1. Explicit specific type (interface/type) — REQUIRED
  2. Generic with constraints — ACCEPTABLE
  3. unknown with immediate validation — ONLY for external data
  4. any — FORBIDDEN

When in doubt, define an interface.