Skip to content

TypeScript

Write type-safe, maintainable TypeScript code that leverages modern language features and provides excellent developer experience.

Always enable strict mode in tsconfig.json:

{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}

Use modern module resolution and target:

{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}

Always provide explicit return types for functions:

// Good
function getUser(id: string): User {
return database.findUser(id);
}
async function fetchData(): Promise<ApiResponse> {
return await api.get('/data');
}
// Avoid
function getUser(id: string) {
return database.findUser(id);
}

Let TypeScript infer variable types when obvious:

// Good - inference is clear
const name = "John";
const count = 42;
const items = ["a", "b", "c"];
// Good - explicit when needed
const user: User = createUser();
const config: Config | null = null;
// Avoid - unnecessary annotation
const name: string = "John";
const count: number = 42;

Always annotate function parameters:

// Good
function greet(name: string, age: number): void {
console.log(`${name} is ${age} years old`);
}
// Bad
function greet(name, age) {
console.log(`${name} is ${age} years old`);
}

Use using for automatic resource disposal:

// Good - automatic cleanup
function processFile(path: string): void {
using file = openFile(path);
// File automatically closed when scope ends
}
// With async
async function query(): Promise<void> {
await using connection = await connectDatabase();
// Connection automatically closed
}

Use satisfies for type checking without widening:

// Good - maintains literal types
const config = {
host: "localhost",
port: 3000,
features: ["auth", "api"]
} satisfies Config;
config.host; // Type: "localhost" (not string)
// Avoid - loses precision
const config: Config = {
host: "localhost",
port: 3000,
features: ["auth", "api"]
};
config.host; // Type: string

Use template literals for type-safe strings:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `/api/${string}`;
type Route = `${HttpMethod} ${Endpoint}`;
// Type: "GET /api/${string}" | "POST /api/${string}" | ...
function handleRoute(route: Route): void {
// Type-safe route handling
}

Use native private fields (#) for true privacy:

// Good - ES2022 private fields
class User {
#password: string;
constructor(password: string) {
this.#password = password;
}
validatePassword(input: string): boolean {
return this.#password === input;
}
}
// Also acceptable - TypeScript private
class User {
constructor(private password: string) {}
}

Use property initialization syntax:

class Component {
// Good - direct initialization
private count = 0;
private items: string[] = [];
// Good - constructor parameter properties
constructor(
private readonly name: string,
private readonly config: Config
) {}
}

Use arrow functions for methods that need binding:

// Good - especially in React/Vue
class Component {
count = 0;
// Auto-bound method
increment = (): void => {
this.count++;
};
// Regular method for non-bound cases
getCount(): number {
return this.count;
}
}

Use interfaces for object shapes, types for unions and utilities:

// Good - interface for object shapes
interface User {
id: string;
name: string;
email: string;
}
// Good - type for unions
type Status = "pending" | "success" | "error";
// Good - type for complex types
type ApiResponse<T> =
| { status: "success"; data: T }
| { status: "error"; error: string };

Use extends for interface composition:

interface Entity {
id: string;
createdAt: Date;
}
interface User extends Entity {
name: string;
email: string;
}

Use & for type composition:

type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User = {
name: string;
email: string;
} & Timestamped;

Use const enums for compile-time constants:

// Good - inlined by compiler
const enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
// Good - with modern bundlers
const direction = Direction.Up; // Inlined to "UP"

For most cases, prefer string literal unions:

// Preferred - simpler and more flexible
type Direction = "up" | "down" | "left" | "right";
const direction: Direction = "up";

Use regular enums when you need reverse mapping:

enum HttpStatus {
OK = 200,
BadRequest = 400,
NotFound = 404
}
HttpStatus.OK; // 200
HttpStatus[200]; // "OK" (reverse mapping)

Leverage TypeScript’s built-in utility types:

// Partial - all properties optional
type PartialUser = Partial<User>;
// Required - all properties required
type RequiredConfig = Required<Config>;
// Pick - select specific properties
type UserPreview = Pick<User, "id" | "name">;
// Omit - exclude specific properties
type UserWithoutPassword = Omit<User, "password">;
// Record - key-value map
type UserMap = Record<string, User>;
// ReturnType - extract return type
type Result = ReturnType<typeof fetchData>;

Create reusable utility types:

// Nullable type
type Nullable<T> = T | null;
// Optional properties
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Deep readonly
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};

Use is predicates for type narrowing:

// Good
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
// Usage
if (isUser(data)) {
console.log(data.name); // Type: User
}

Use assertion functions for runtime checks:

function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error("Not a user");
}
}
// Usage
assertIsUser(data);
console.log(data.name); // Type: User (after assertion)

Use constraints for type safety:

// 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>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}

Provide sensible defaults:

// Good - default type parameter
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
// Usage
const response: ApiResponse = { data: "hello", status: 200 };
const typedResponse: ApiResponse<User> = { data: user, status: 200 };

Explicitly type Promise return values:

// Good
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Good - with error handling
async function fetchUser(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
return await response.json();
} catch {
return null;
}
}

Use Promise combinators with types:

// All
const results = await Promise.all<[User, Post[], Comment[]]>([
fetchUser(id),
fetchPosts(id),
fetchComments(id)
]);
// AllSettled
const settled = await Promise.allSettled([
fetchUser("1"),
fetchUser("2")
]);

Never use any except for migration or FFI:

// Bad
function process(data: any): any {
return data.value;
}
// Good - use unknown
function process(data: unknown): string {
if (isValidData(data)) {
return data.value;
}
throw new Error("Invalid data");
}

Be consistent with nullability:

// Good - use undefined for optional values
interface User {
id: string;
name: string;
email?: string; // Optional, may be undefined
}
// Good - use null for explicit absence
interface ApiResponse {
data: User | null; // Explicitly no data
error: string | null;
}

Use modern operators for null handling:

// Good - optional chaining
const email = user?.profile?.email;
// Good - nullish coalescing
const name = user?.name ?? "Anonymous";
// Combined
const display = user?.profile?.displayName ?? user?.name ?? "Guest";

Prefer named exports for better refactoring:

// Good - named exports
export function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
export class ShoppingCart {
// ...
}
// Import
import { calculateTotal, ShoppingCart } from "./cart";

Use default exports for framework components or single-purpose modules:

// Good - component default export
export default function UserProfile({ user }: Props): JSX.Element {
return <div>{user.name}</div>;
}
// Good - single purpose module
export default class ApiClient {
// ...
}

Use type modifier for type imports:

// Good - explicit type imports
import type { User, Post } from "./types";
import { fetchUser } from "./api";
// Good - mixed imports
import { fetchUser, type ApiResponse } from "./api";

Use discriminated unions for error handling:

// Good - Result type pattern
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: error as Error };
}
}
// Usage
const result = await fetchUser("123");
if (result.success) {
console.log(result.data.name);
} else {
console.error(result.error.message);
}

Create typed error classes:

class ValidationError extends Error {
constructor(
message: string,
public field: string
) {
super(message);
this.name = "ValidationError";
}
}
class ApiError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = "ApiError";
}
}

Use clear, descriptive names:

// Good
interface UserProfile {
displayName: string;
avatarUrl: string;
}
function calculateTotalPrice(items: CartItem[]): number {
// ...
}
// Bad
interface UP {
dn: string;
au: string;
}
function calc(i: CartItem[]): number {
// ...
}
  • PascalCase - Classes, interfaces, types, enums, type parameters
  • camelCase - Variables, functions, methods, parameters
  • UPPER_SNAKE_CASE - Constants
  • lowercase - File names with hyphens
// Classes and types
class UserService {}
interface ApiResponse {}
type HttpMethod = "GET" | "POST";
// Variables and functions
const userName = "John";
function fetchData(): void {}
// Constants
const MAX_RETRY_ATTEMPTS = 3;
const API_BASE_URL = "https://api.example.com";
// File names
// user-service.ts
// api-client.ts
// http-utils.ts

✅ Enable strict mode in tsconfig.json ✅ Use explicit return types for functions ✅ Leverage modern TypeScript features (using, satisfies) ✅ Use native private fields (#) for true encapsulation ✅ Prefer unknown over any ✅ Use const enums with modern bundlers ✅ Create type guards for runtime validation ✅ Use utility types to avoid repetition ✅ Provide type constraints for generics ✅ Use discriminated unions for state management

❌ Use any (use unknown instead) ❌ Skip return type annotations ❌ Use as casts without validation ❌ Ignore TypeScript errors ❌ Use non-null assertion (!) carelessly ❌ Disable strict mode ❌ Mix module systems (ESM/CommonJS) ❌ Use type assertions for coercion ❌ Create overly complex type hierarchies

  • ESLint - Code linting with typescript-eslint
  • Prettier - Code formatting
  • ts-prune - Find unused exports
  • tsc-watch - Watch mode compilation
  • tsc - TypeScript compiler
  • ts-node - Execute TypeScript directly
  • tsx - Fast TypeScript execution

.eslintrc.json:

{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/strict-boolean-expressions": "error"
}
}