TypeScript Style Guide

February 22, 2026 ยท View on GitHub

Purpose: Standardized TypeScript conventions for type-safe application development.

Scope

This document covers: strict configuration, ESLint integration, type system patterns, generic constraints, and utility types.

Related:


Quick Reference

StandardValueValidation
TypeScript5.0+tsc --version
Strict ModeRequired"strict": true in tsconfig.json
LinterESLint + typescript-eslinteslint . --ext .ts,.tsx
FormatterPrettier.prettierrc at repo root
Gatetsc --noEmit must passCI check

Strict Configuration

tsconfig.json

Every TypeScript project MUST use strict mode:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Why strict matters:

  • strict: true enables all strict type-checking options
  • noUncheckedIndexedAccess adds undefined to index signatures
  • exactOptionalPropertyTypes distinguishes undefined from missing

ESLint Configuration

eslint.config.js (Flat Config)

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        project: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/explicit-function-return-type': 'error',
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/prefer-nullish-coalescing': 'error',
      '@typescript-eslint/prefer-optional-chain': 'error',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/await-thenable': 'error',
    },
  },
  {
    ignores: ['dist/', 'node_modules/', '*.js'],
  }
);

Usage:

# Lint check
npx eslint . --ext .ts,.tsx

# Fix auto-fixable issues
npx eslint . --ext .ts,.tsx --fix

# Type check only (no emit)
npx tsc --noEmit

Type System Patterns

Prefer Type Inference

Let TypeScript infer types when obvious:

// Good - inference is clear
const users = ['alice', 'bob'];
const count = users.length;

// Good - explicit when non-obvious or API boundary
function getUser(id: string): User | undefined {
  return userMap.get(id);
}

// Bad - redundant annotation
const name: string = 'alice';

Discriminated Unions

Use discriminated unions for state modeling:

// Good - exhaustive pattern matching
type Result<T, E> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: E };

function handleResult<T, E>(result: Result<T, E>): void {
  switch (result.status) {
    case 'success':
      console.log(result.data);
      break;
    case 'error':
      console.error(result.error);
      break;
    // TypeScript enforces exhaustiveness
  }
}

Const Assertions

Use as const for literal types:

// Good - preserves literal types
const CONFIG = {
  apiVersion: 'v1',
  retries: 3,
  endpoints: ['primary', 'fallback'],
} as const;

// Type: { readonly apiVersion: "v1"; readonly retries: 3; ... }

Generic Constraints

Constrained Generics

Always constrain generics when possible:

// Good - constrained generic
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// Good - multiple constraints
function merge<T extends object, U extends object>(a: T, b: U): T & U {
  return { ...a, ...b };
}

// Bad - unconstrained (allows any)
function unsafe<T>(value: T): T {
  return value;
}

Generic Defaults

Provide defaults for optional type parameters:

interface ApiResponse<T = unknown, E = Error> {
  data?: T;
  error?: E;
  status: number;
}

// Uses defaults
const response: ApiResponse = { status: 200 };

// Override defaults
const typed: ApiResponse<User, ApiError> = { status: 200 };

Utility Types

Built-in Utilities

Use built-in utility types over manual definitions:

// Partial - all properties optional
type PartialUser = Partial<User>;

// Required - all properties required
type RequiredConfig = Required<Config>;

// Pick - select properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit - exclude properties
type UserWithoutPassword = Omit<User, 'password'>;

// Record - typed object
type UserMap = Record<string, User>;

// Extract/Exclude - union manipulation
type StringOrNumber = Extract<string | number | boolean, string | number>;

Custom Type Helpers

Create reusable type utilities:

// Deep partial
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// Non-nullable object values
type NonNullableValues<T> = {
  [K in keyof T]: NonNullable<T[K]>;
};

// Extract function return types from object
type ReturnTypes<T extends Record<string, (...args: never[]) => unknown>> = {
  [K in keyof T]: ReturnType<T[K]>;
};

Conditional Types

Type-Level Logic

Use conditional types for dynamic typing:

// Infer array element type
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

// Flatten promise type
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;

// Function parameter extraction
type FirstParam<T> = T extends (first: infer P, ...args: never[]) => unknown
  ? P
  : never;

Template Literal Types

Use template literals for string manipulation:

// Event handler naming
type EventName = 'click' | 'change' | 'submit';
type HandlerName = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onChange" | "onSubmit"

// Path building
type ApiPath<T extends string> = `/api/v1/${T}`;
type UserPath = ApiPath<'users'>; // "/api/v1/users"

Error Handling

Result Pattern

Prefer explicit error handling over exceptions:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseJson<T>(json: string): Result<T, SyntaxError> {
  try {
    return { ok: true, value: JSON.parse(json) as T };
  } catch (e) {
    return { ok: false, error: e as SyntaxError };
  }
}

// Usage
const result = parseJson<User>(input);
if (result.ok) {
  console.log(result.value.name);
} else {
  console.error(result.error.message);
}

Type Guards

Use type guards for runtime type narrowing:

// User-defined type guard
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  );
}

// Assertion function
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error('Invalid user');
  }
}

Module Template

Standard template for TypeScript modules:

/**
 * Module description.
 * @module module-name
 */

// Types first
export interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

export type Handler<T> = (data: T) => Promise<void>;

// Constants
const DEFAULT_TIMEOUT = 5000;

// Private helpers (not exported)
function validateConfig(config: Config): void {
  if (!config.apiUrl) {
    throw new Error('apiUrl is required');
  }
}

// Public API
export function createClient(config: Config): Client {
  validateConfig(config);
  return new Client(config);
}

export class Client {
  readonly #config: Config;

  constructor(config: Config) {
    this.#config = config;
  }

  async fetch<T>(path: string): Promise<T> {
    const response = await fetch(`${this.#config.apiUrl}${path}`);
    return response.json() as Promise<T>;
  }
}

Summary Checklist

RequirementCheck
"strict": true in tsconfig.jsonRequired
noUncheckedIndexedAccess enabledRequired
ESLint with typescript-eslintRequired
tsc --noEmit passesCI gate
No any typesEnforced via ESLint
Explicit return types on exportsRequired
Discriminated unions for statesPreferred
Type guards for runtime checksPreferred
Generic constraintsRequired when using generics
Built-in utility typesPreferred over custom

Key Takeaways:

  1. TypeScript 5.0+ with strict mode required
  2. ESLint with strictTypeChecked config
  3. tsc --noEmit must pass before merge
  4. Prefer type inference, annotate at API boundaries
  5. Use discriminated unions for state modeling
  6. Always constrain generic type parameters
  7. Use built-in utility types over manual definitions
  8. Prefer Result pattern over thrown exceptions

Common Errors

SymptomCauseFix
TS2322: Type X not assignable to YType mismatchCheck types, add assertion or fix data
TS2345: Argument of type XWrong function argumentCheck function signature
TS2531: Object is possibly nullNull safety check missingAdd if (x) or use x! if certain
TS2339: Property does not existMissing type definitionAdd to interface or use type guard
TS7006: Parameter has implicit anyMissing type annotationAdd explicit type annotation
TS2554: Expected N arguments, got MWrong argument countCheck function signature
TS2769: No overload matchesWrong argument typesCheck overload signatures
TS6133: Variable declared but never usedUnused variableRemove or prefix with _
ESLint: @typescript-eslint/no-floating-promisesUnhandled promiseAdd await or void prefix

Anti-Patterns

NamePatternWhy BadInstead
Any Escapeas any or as unknown as TDefeats type safetyFix the types, use type guards
Non-null Assertion Spamx!.y!.z!Runtime errors if wrongProper null checks
Type-Only Imports Missingimport { Type }Bundler includes unusedimport type { Type }
Index Signature Abuse[key: string]: anyNo type safetyExplicit properties or generics
Enum for Stringsenum Color { Red = "RED" }Verbose, poor tree-shakingUnion: type Color = "RED" | "BLUE"
Interface for Everythinginterface X {} for simple objectsUnnecessary abstractiontype X = {...} for simple cases
Callback HellNested .then() chainsHard to read/debugasync/await

AI Agent Guidelines

When AI agents write TypeScript for this repo:

GuidelineRationale
ALWAYS run tsc --noEmit before committingCatches type errors
ALWAYS use import type for type-only importsSmaller bundles
NEVER use any without comment explaining whyMaintain type safety
NEVER ignore ESLint errors with // eslint-disableFix the issue
PREFER unknown over any for untyped dataSafer, requires checks
PREFER const assertions for literalsBetter type inference
PREFER discriminated unions for stateExhaustiveness checking