| |

TypeScript 104 🔷 TypeScript Style Guide and Best Practices

TypeScript gives you a type system, not a style guide. The compiler enforces that a string is not a number, but it does not enforce that your interfaces are named consistently, that your functions return early instead of nesting deeply, or that your imports are ordered in a predictable way. Those decisions are conventions, and conventions are what make a large codebase readable by more than one person.

This chapter collects the conventions that the TypeScript community has converged on. Some are enforced by lint rules, some by editor configuration, and some by code review. None are enforced by the compiler. The goal is not to prescribe a single “correct” style — it is to make the choices explicit so that a team can agree on them and apply them consistently.

Key point: TypeScript’s style conventions exist for two reasons: consistency and safety. Consistency means a developer reading any file in the codebase sees the same patterns. Safety means the conventions nudge toward code that is harder to misuse. A style guide that only serves consistency is a preference. A style guide that also serves safety is a discipline.


Why a style guide matters

Code is read far more often than it is written. A function is written once and read dozens of times. An interface is defined once and referenced throughout the codebase. The cost of a confusing name or an inconsistent pattern is paid every time someone reads the code.

The review cost. Without conventions, code review debates become personal. One reviewer prefers interface for object shapes; another prefers type. One prefers IUser; another prefers User. The debate is not about correctness — it is about preference. A style guide moves the preference to a decision made once, at the team level, so the review can focus on logic.

The onboarding cost. A new developer joining a codebase with consistent conventions learns the patterns once and applies them everywhere. A codebase with inconsistent conventions requires the developer to learn each file separately. The style guide is documentation of the patterns.

The safety cost. Some conventions are not just about aesthetics. Preferring unknown over any prevents a class of type errors. Preferring readonly on arrays that should not be mutated prevents accidental mutation. Preferring discriminated unions over optional fields prevents impossible states. These conventions encode safety, not preference.

The tooling cost. A codebase with consistent conventions can enforce them with ESLint and Prettier. A codebase with inconsistent conventions cannot. The tooling amplifies the convention: once the rule is configured, the editor and CI enforce it automatically.

The trade-off. A style guide adds friction. A developer who wants to write type User = { ... } is told to write interface User { ... } instead. The friction is small, but it is real. The benefit is that the codebase is readable by everyone on the team, not just the person who wrote the file.


a. Naming Conventions

TypeScript has no compiler-enforced naming rules, but the community has settled on a set of conventions that most style guides share.

Types, interfaces, classes, and enums use PascalCase. A type is a thing, and things are named with capital letters. User, Order, PaymentMethod, ApiResponse — each name describes a shape or a concept.

Variables, functions, methods, and parameters use camelCase. userName, getUserById, formatDate, isValid — each name describes an action or a value.

Constants use SCREAMING_SNAKE_CASE. A constant is a value that does not change. MAX_RETRY_COUNT, API_BASE_URL, DEFAULT_TIMEOUT_MS. This convention distinguishes constants from regular variables at a glance.

Boolean variables and functions that return booleans use a prefix. isActive, hasPermission, canEdit, shouldUpdate. The prefix makes the type obvious at the call site: if (user.isActive) reads as a question, and the answer is clearly boolean.

Generic type parameters use a single uppercase letter or a descriptive name. T for a generic type, K for a key, V for a value. When the meaning is not obvious, a descriptive name is better: TElement, TResult, TKey.

Interfaces and type aliases do not use an I prefix. The IUser convention comes from C# and older Java codebases. The TypeScript community has largely abandoned it. An interface named User is clearer than one named IUser, and the distinction between interface and type alias is an implementation detail that callers should not care about.

Enums use PascalCase for the name and PascalCase for the members. enum Color { Red, Green, Blue }. Some style guides prefer SCREAMING_SNAKE_CASE for enum members, but PascalCase is more common in TypeScript.

The conventions are codified in the @typescript-eslint/naming-convention rule, which can enforce them automatically. The rule is configurable, so a team can adjust the conventions to its own preferences, but the defaults match the community consensus.


b. Type and Interface Conventions

The choice between interface and type is one of the most discussed style questions in TypeScript. The two are largely interchangeable for object shapes, but there are differences that matter.

Use interface for object shapes that may be extended. An interface can be extended with extends and merged with declaration merging. A type alias cannot be merged. If the shape is a contract that consumers will extend, interface is the right choice.

Use type for unions, intersections, and mapped types. An interface cannot express type Status = 'active' | 'inactive'. A type alias can. Unions, intersections, conditional types, and mapped types all require the type keyword.

Use type for function types. type Handler = (event: Event) => void is clearer than an interface with a call signature. Both work, but the type alias is more direct.

Prefer readonly on arrays and properties that should not be mutated. A function that accepts an array and does not modify it should declare readonly string[] rather than string[]. This prevents the function from accidentally calling push or splice, and it allows the caller to pass a frozen array.

Prefer discriminated unions over optional fields. A type with status: 'loading' | 'success' | 'error' and optional data and error fields allows impossible states: status: 'loading' with a populated error field. A discriminated union makes the impossible states impossible:

type RequestState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

The compiler narrows the type correctly when status is checked, and the data and error fields are only accessible in the branches where they exist.

Prefer unknown over any. The any type disables type checking for the entire expression and everything it touches. The unknown type forces the caller to narrow the type before using it. The noImplicitAny compiler option makes this a discipline: any unannotated parameter is an error.

Prefer type inference over explicit annotations when the type is obvious. A variable initialized with a literal does not need an annotation: const count = 0 is better than const count: number = 0. The inference is correct, and the annotation is noise. The exception is when the inferred type is wider than intended — const status = 'active' infers string when the intent is 'active' | 'inactive'. In that case, the explicit annotation is necessary.


c. Functions and Modules

Function and module conventions determine how code is structured and how imports are organized.

Use arrow functions for callbacks and methods that do not need this. Arrow functions capture this from the enclosing scope, which is what you want in most callbacks. Regular functions are necessary when the function is a method, a constructor, or a generator.

Prefer named exports over default exports. A default export can be renamed arbitrarily at the import site, which makes refactoring harder and reduces the value of search. A named export export function formatUser must be imported as formatUser. The name is stable. The import/no-default-export rule enforces this convention.

Order imports consistently. A common order is: Node.js built-ins, external packages, internal absolute imports, relative imports. Within each group, imports are sorted alphabetically. The import/order rule enforces this automatically.

Use import type for type-only imports. An import that is only used for types should be declared with import type. This tells the compiler that the import has no runtime effect, which allows bundlers to remove it. The @typescript-eslint/consistent-type-imports rule enforces this.

Prefer early returns over nested conditionals. A function that checks for an error condition and returns early is easier to read than one that nests the main logic inside an if block. The early return flattens the function and makes the happy path the last thing in the function.

// Prefer early returns
function processUser(user: User | null): string {
  if (!user) {
    return 'No user';
  }
  if (!user.email) {
    return 'No email';
  }
  return user.email;
}

// Over deeply nested conditionals
function processUser(user: User | null): string {
  if (user) {
    if (user.email) {
      return user.email;
    } else {
      return 'No email';
    }
  } else {
    return 'No user';
  }
}

Prefer const over let. A variable that is not reassigned should be declared with const. The prefer-const rule enforces this. The let keyword is only for variables that are reassigned.

Use optional chaining and nullish coalescing over manual checks. user?.profile?.name is clearer than user && user.profile && user.profile.name. value ?? defaultValue is clearer than value !== null && value !== undefined ? value : defaultValue.


Complete Example Session

This session demonstrates the conventions applied to a small module.

// ============================================
// PART 1: NAMING CONVENTIONS
// ============================================

// PascalCase for types and interfaces
interface User {
  id: string;
  name: string;
  email: string;
}

type ApiResponse<T> = {
  data: T;
  status: number;
};

// camelCase for variables, functions, and parameters
const currentUser: User = { id: '1', name: 'Alice', email: 'alice@example.com' };

function getUserById(userId: string): User | undefined {
  return userId === currentUser.id ? currentUser : undefined;
}

// SCREAMING_SNAKE_CASE for constants
const MAX_RETRY_COUNT = 3;
const API_BASE_URL = 'https://api.example.com';

// Boolean prefix
const isActive = true;
const hasPermission = false;
const canEdit = true;

// ============================================
// PART 2: TYPE VS INTERFACE
// ============================================

// interface for object shapes that may be extended
interface Entity {
  id: string;
}

interface UserEntity extends Entity {
  name: string;
  email: string;
}

// type for unions
type Status = 'active' | 'inactive' | 'pending';

// type for function types
type UserPredicate = (user: User) => boolean;

// type for mapped types
type PartialUser = Partial<User>;

// ============================================
// PART 3: READONLY AND IMMUTABILITY
// ============================================

// readonly array parameter
function sum(numbers: readonly number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}

// readonly properties
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

// ============================================
// PART 4: DISCRIMINATED UNIONS
// ============================================

// Prefer discriminated unions over optional fields
type RequestState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function renderState<T>(state: RequestState<T>): string {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Data: ${JSON.stringify(state.data)}`;
    case 'error':
      return `Error: ${state.error.message}`;
  }
}

// ============================================
// PART 5: UNKNOWN OVER ANY
// ============================================

// Prefer unknown
function parseJson(input: string): unknown {
  return JSON.parse(input);
}

function processJson(input: string): string {
  const data = parseJson(input);
  if (typeof data === 'object' && data !== null && 'name' in data) {
    return String((data as { name: unknown }).name);
  }
  return 'unknown';
}

// ============================================
// PART 6: TYPE INFERENCE
// ============================================

// Inference is enough for obvious cases
const count = 0;              // number
const name = 'Alice';         // string
const items = [1, 2, 3];      // number[]

// Explicit annotation when the inferred type is wider
type Status2 = 'active' | 'inactive';
const status: Status2 = 'active';

// ============================================
// PART 7: NAMED EXPORTS
// ============================================

// Prefer named exports
export function formatUser(user: User): string {
  return `${user.name} <${user.email}>`;
}

export interface UserProfile {
  user: User;
  role: string;
}

// Over default exports
// export default function formatUser(user: User) { ... }

// ============================================
// PART 8: IMPORT ORDER
// ============================================

// 1. Node.js built-ins
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

// 2. External packages
import { z } from 'zod';
import express from 'express';

// 3. Internal absolute imports
import { User } from '@/types/user';
import { formatUser } from '@/utils/format';

// 4. Relative imports
import { config } from './config';
import { logger } from './logger';

// ============================================
// PART 9: IMPORT TYPE
// ============================================

// Type-only imports
import type { User } from './types';
import type { RequestState } from './state';

// Value imports
import { formatUser } from './format';

// Combined
import { formatUser, type User } from './format';

// ============================================
// PART 10: EARLY RETURNS
// ============================================

// Prefer early returns
function getUserEmail(user: User | null): string {
  if (!user) {
    return 'No user';
  }
  if (!user.email) {
    return 'No email';
  }
  return user.email;
}

// Over nested conditionals
function getUserEmailNested(user: User | null): string {
  if (user) {
    if (user.email) {
      return user.email;
    } else {
      return 'No email';
    }
  } else {
    return 'No user';
  }
}

The ten parts cover naming conventions, type vs interface, readonly and immutability, discriminated unions, unknown over any, type inference, named exports, import order, import type, and early returns.


Quick Reference

Naming Conventions

ElementConventionExample
Type, interface, classPascalCaseUser, ApiResponse
Variable, function, methodcamelCaseuserName, getUser
ConstantSCREAMING_SNAKE_CASEMAX_RETRY_COUNT
Booleanis/has/can/should prefixisActive, hasPermission
Generic typeSingle letter or descriptiveT, TElement
EnumPascalCaseColor.Red

Type vs Interface

Use CaseKeyword
Object shape that may be extendedinterface
Union, intersection, mapped typetype
Function typetype
Primitive aliastype
Declaration merging neededinterface

Safety Conventions

ConventionReason
unknown over anyForces narrowing
readonly on non-mutated arraysPrevents mutation
Discriminated unionsPrevents impossible states
import typeEnables tree-shaking
Early returnsFlattens conditionals

Module Conventions

ConventionReason
Named exportsStable names, searchable
No default exportsRefactorable, consistent
Import orderPredictable reading
Absolute imports for internalStable paths
Relative for same directoryLocality

The ESLint Rules

RuleEnforces
@typescript-eslint/naming-conventionNaming patterns
@typescript-eslint/consistent-type-importsimport type
@typescript-eslint/no-explicit-anyNo any
@typescript-eslint/prefer-readonlyReadonly arrays
import/orderImport ordering
import/no-default-exportNamed exports
prefer-constconst over let

Best Practices

✅ Do This:

// Use PascalCase for types
interface User { id: string; }                                 // ✅
// Use camelCase for variables and functions
const userName = 'Alice';
function getUser() { }                                          // ✅
// Use is/has/can for booleans
const isActive = true;                                          // ✅
// Use unknown over any
function parse(data: unknown): string { ... }                   // ✅
// Use discriminated unions
type State = { status: 'loading' } | { status: 'success'; data: T }; // ✅
// Use import type for type-only imports
import type { User } from './types';                            // ✅
// Use early returns
if (!user) return 'No user';                                    // ✅

❌ Don’t Do This:

// Don't use I prefix for interfaces
interface IUser { id: string; }                                 // ❌
// Don't use any
function parse(data: any): string { ... }                       // ❌
// Don't use default exports
export default function formatUser() { }                        // ❌
// Don't use optional fields for mutually exclusive states
type State = { status: string; data?: T; error?: Error };       // ❌
// Don't nest conditionals when early returns are possible
if (user) { if (user.email) { return user.email; } }            // ❌

Common Pitfalls

PitfallWhy It HappensFix
Inconsistent namingNo rule configuredEnable naming-convention
any accumulatesUsing any to silence errorsUse unknown
Impossible statesOptional fields for exclusive casesUse discriminated unions
Bundle includes unused importsNo import typeEnable consistent-type-imports
Deep nestingNo early return conventionRefactor to early returns
Import order chaosNo import/order ruleEnable the rule
Mutable state surprisesNo readonly conventionUse readonly on parameters

Real-World Examples

1. Naming

interface User { id: string; name: string; }
const currentUser: User = { id: '1', name: 'Alice' };
const MAX_RETRY = 3;
const isActive = true;

2. Type vs Interface

interface Entity { id: string; }
type Status = 'active' | 'inactive';
type Handler = (event: Event) => void;

3. Readonly

function sum(numbers: readonly number[]): number {
  return numbers.reduce((a, b) => a + b, 0);
}

4. Discriminated Union

type State<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

5. Unknown

function parse(data: unknown): string {
  if (typeof data === 'string') return data;
  return '';
}

6. Type Inference

const count = 0;
const name = 'Alice';
const items = [1, 2, 3];

7. Named Export

export function formatUser(user: User): string { ... }

8. Import Order

import { readFile } from 'node:fs/promises';
import { z } from 'zod';
import { User } from '@/types/user';
import { config } from './config';

9. Import Type

import type { User } from './types';

10. Early Return

if (!user) return 'No user';
if (!user.email) return 'No email';
return user.email;

Visual

The Naming Conventions

┌──────────────────────────────────────────────┐
│  NAMING CONVENTIONS                          │
│                                              │
│  PascalCase:                                 │
│    User, Order, ApiResponse, Color           │
│    (types, interfaces, classes, enums)       │
│                                              │
│  camelCase:                                  │
│    userName, getUser, formatDate, isValid    │
│    (variables, functions, methods)           │
│                                              │
│  SCREAMING_SNAKE_CASE:                       │
│    MAX_RETRY_COUNT, API_BASE_URL             │
│    (constants)                               │
│                                              │
│  is/has/can prefix:                          │
│    isActive, hasPermission, canEdit          │
│    (booleans)                                │
│                                              │
│  T, K, V, TElement:                          │
│    (generic type parameters)                 │
│                                              │
└──────────────────────────────────────────────┘

The Type vs Interface Decision

┌──────────────────────────────────────────────┐
│  type vs interface                           │
│                                              │
│  Use interface:                              │
│    ├─ Object shape                           │
│    ├─ May be extended                        │
│    └─ Declaration merging needed             │
│                                              │
│  Use type:                                   │
│    ├─ Union: type S = 'a' | 'b'              │
│    ├─ Intersection: type X = A & B           │
│    ├─ Mapped: type P = Partial<T>            │
│    ├─ Conditional: type C = T extends U ...  │
│    └─ Function: type F = (x: A) => B         │
│                                              │
└──────────────────────────────────────────────┘

The Safety Conventions

┌──────────────────────────────────────────────┐
│  SAFETY OVER PREFERENCE                      │
│                                              │
│  unknown over any:                           │
│    └─ Forces narrowing at call site          │
│                                              │
│  readonly over mutable:                      │
│    └─ Prevents accidental mutation           │
│                                              │
│  Discriminated unions:                       │
│    └─ Prevents impossible states             │
│                                              │
│  import type:                                │
│    └─ Enables tree-shaking                   │
│                                              │
│  Early returns:                              │
│    └─ Flattens control flow                  │
│                                              │
└──────────────────────────────────────────────┘

The ESLint Enforcement

┌──────────────────────────────────────────────┐
│  ESLINT ENFORCEMENT                          │
│                                              │
│  naming-convention                           │
│    └─ Enforces PascalCase/camelCase rules    │
│                                              │
│  consistent-type-imports                     │
│    └─ Enforces import type                   │
│                                              │
│  no-explicit-any                             │
│    └─ Bans any                               │
│                                              │
│  import/order                                │
│    └─ Enforces import order                  │
│                                              │
│  no-default-export                           │
│    └─ Enforces named exports                 │
│                                              │
│  Each rule codifies one convention.          │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Types, interfaces, classesPascalCase
Variables, functionscamelCase
ConstantsSCREAMING_SNAKE_CASE
Booleansis/has/can/should prefix
GenericsT or descriptive name
Object shapeinterface
Union, mapped, conditionaltype
Function typetype
Safe default for unknownunknown
Mutabilityreadonly where possible
State modelingDiscriminated unions
ExportsNamed, not default
ImportsOrdered, type-only with import type
Control flowEarly returns

Key takeaways:

  • Naming conventions distinguish types from values. PascalCase for types, interfaces, and classes. camelCase for variables, functions, and methods. SCREAMING_SNAKE_CASE for constants. A prefix (is, has, can, should) for booleans. A single letter or a descriptive name for generics.
  • interface for object shapes, type for everything else. An interface can be extended and merged. A type alias can express unions, intersections, mapped types, conditional types, and function types. Both are valid for object shapes; the choice depends on whether extension or composition is the goal.
  • unknown is the safe default for values whose type is not known. The any type disables type checking for the entire expression. The unknown type forces the caller to narrow the type before using it. The noImplicitAny compiler option makes this a discipline.
  • readonly on arrays and properties that should not be mutated. A function that accepts an array and does not modify it should declare readonly T[]. A property that is set once and never changed should be readonly. The compiler enforces the convention.
  • Discriminated unions prevent impossible states. A type with a status discriminant and variant-specific fields makes the impossible states unrepresentable. The compiler narrows the type correctly when the discriminant is checked.
  • Named exports are more refactorable than default exports. A named export has a stable name. A default export can be renamed arbitrarily at the import site, which reduces the value of search and makes refactoring harder.
  • Import order and import type make modules predictable. A consistent import order (built-ins, external, internal, relative) makes files easier to scan. import type for type-only imports allows bundlers to remove them entirely.

Remember: A style guide is a set of decisions made once so that they do not need to be made again. Naming conventions, type vs interface, unknown vs any, readonly vs mutable, discriminated unions vs optional fields — each choice is small, but together they determine whether a codebase is readable by more than one person. Some conventions are preferences. Some are safety measures. The safety measures are the ones to enforce with lint rules. The preferences are the ones to document and follow. The compiler does not enforce style, but the team can.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!