TypeScript
TypeScript Best Practices
TypeScript Best Practices
Writing maintainable, type-safe TypeScript code requires following established best practices. In this lesson, we'll cover strict mode configuration, avoiding the any type, proper typing strategies, linting configuration, and common mistakes to avoid.
Enable Strict Mode
Always enable strict mode for maximum type safety:
// tsconfig.json
{
"compilerOptions": {
"strict": true, // Enables all strict checks
// Or enable individually:
"strictNullChecks": true, // null/undefined must be explicit
"strictFunctionTypes": true, // Function parameters contravariant
"strictBindCallApply": true, // Type-check bind/call/apply
"strictPropertyInitialization": true, // Class properties must be initialized
"noImplicitThis": true, // Error on unclear 'this'
"alwaysStrict": true, // Use strict mode in output
"noImplicitAny": true, // Error on implicit 'any'
// Additional helpful checks
"noUnusedLocals": true, // Error on unused variables
"noUnusedParameters": true, // Error on unused parameters
"noImplicitReturns": true, // All code paths must return
"noFallthroughCasesInSwitch": true, // Switch cases must break/return
"noUncheckedIndexedAccess": true // Index access returns T | undefined
}
}
Note: Enabling
strict: true automatically enables all strict type-checking options. It's the recommended baseline for all TypeScript projects.
Avoid the 'any' Type
The any type defeats the purpose of TypeScript. Use alternatives:
// ❌ Bad: Using any
function processData(data: any) {
return data.value.toUpperCase(); // No type checking!
}
// ✅ Good: Use unknown for truly unknown types
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
const obj = data as { value: unknown };
if (typeof obj.value === 'string') {
return obj.value.toUpperCase();
}
}
throw new Error('Invalid data');
}
// ✅ Better: Define proper types
interface Data {
value: string;
}
function processData(data: Data) {
return data.value.toUpperCase();
}
// ✅ Good: Use generics for flexible types
function processData<T extends { value: string }>(data: T) {
return data.value.toUpperCase();
}
When to Use 'unknown' vs 'any'
// unknown: Type-safe way to represent unknown values
function parseJSON(json: string): unknown {
return JSON.parse(json);
}
// Must narrow type before use
const result = parseJSON('{"name": "John"}');
// Type guard required
if (typeof result === 'object' && result !== null && 'name' in result) {
console.log((result as { name: string }).name);
}
// any: Only use when absolutely necessary (e.g., migrating JS)
// Prefer unknown instead
declare function legacyAPI(): any; // Legacy code
Warning: Use
any only as a last resort. It disables type checking and can hide bugs. Prefer unknown, which requires type narrowing before use.
Proper Null and Undefined Handling
// ❌ Bad: Not handling null/undefined
interface User {
name: string;
email?: string; // Optional
}
function sendEmail(user: User) {
const email = user.email;
// Error with strictNullChecks: email might be undefined
return email.toLowerCase();
}
// ✅ Good: Explicit checks
function sendEmail(user: User) {
if (!user.email) {
throw new Error('Email required');
}
return user.email.toLowerCase(); // Safe now
}
// ✅ Good: Optional chaining
function sendEmail(user: User) {
return user.email?.toLowerCase() ?? 'no-email';
}
// ✅ Good: Nullish coalescing
function getDisplayName(name: string | null | undefined) {
return name ?? 'Anonymous';
}
Type Assertions vs Type Guards
// ❌ Bad: Type assertions without validation
function processResponse(response: unknown) {
const data = response as { items: string[] };
return data.items.length; // Unsafe!
}
// ✅ Good: Type guards with runtime validation
interface ApiResponse {
items: string[];
}
function isApiResponse(value: unknown): value is ApiResponse {
return (
typeof value === 'object' &&
value !== null &&
'items' in value &&
Array.isArray((value as ApiResponse).items) &&
(value as ApiResponse).items.every(item => typeof item === 'string')
);
}
function processResponse(response: unknown) {
if (!isApiResponse(response)) {
throw new Error('Invalid response');
}
return response.items.length; // Safe!
}
// ✅ Good: Use libraries like zod for validation
import { z } from 'zod';
const ApiResponseSchema = z.object({
items: z.array(z.string())
});
type ApiResponse = z.infer<typeof ApiResponseSchema>;
function processResponse(response: unknown) {
const data = ApiResponseSchema.parse(response);
return data.items.length; // Validated and typed!
}
Prefer Interfaces Over Type Aliases (Usually)
// ✅ Good: Interfaces for object shapes
interface User {
id: string;
name: string;
email: string;
}
// Interfaces can be extended and merged
interface User {
createdAt: Date; // Declaration merging
}
interface Admin extends User {
permissions: string[];
}
// ✅ Good: Type aliases for unions, intersections, primitives
type Status = 'pending' | 'approved' | 'rejected';
type ID = string | number;
type Nullable<T> = T | null;
// ✅ Good: Type aliases for complex types
type ApiResponse<T> = {
data: T;
status: number;
} | {
error: string;
status: number;
};
Tip: Use interfaces for object types that might be extended. Use type aliases for unions, intersections, and mapped types.
Proper Function Typing
// ❌ Bad: Implicit return types
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// ✅ Good: Explicit types
function calculateTotal(items: Array<{ price: number }>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
// ✅ Good: Use readonly for immutability
function calculateTotal(items: ReadonlyArray<{ readonly price: number }>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
// ✅ Good: Async functions should return Promise
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// ❌ Bad: Callback without proper types
function fetchData(callback) {
callback(null, data);
}
// ✅ Good: Properly typed callback
function fetchData(callback: (error: Error | null, data: string) => void): void {
callback(null, 'data');
}
Array and Object Typing
// ✅ Prefer Array<T> syntax for complex types
const users: Array<User> = [];
const matrix: Array<Array<number>> = [];
// ✅ T[] is fine for simple types
const numbers: number[] = [1, 2, 3];
const strings: string[] = ['a', 'b', 'c'];
// ✅ Use readonly for immutable arrays
function processItems(items: readonly string[]): void {
// items.push('new'); // Error: readonly
console.log(items.length); // OK
}
// ✅ Tuple types for fixed-length arrays
type Point = [number, number];
type RGB = [red: number, green: number, blue: number];
const point: Point = [10, 20];
const color: RGB = [255, 128, 0];
// ✅ Use Record for objects with dynamic keys
const userMap: Record<string, User> = {
'user1': { id: '1', name: 'John', email: 'john@example.com' },
'user2': { id: '2', name: 'Jane', email: 'jane@example.com' }
};
// ✅ Index signatures for flexible objects
interface Dictionary {
[key: string]: number;
}
const scores: Dictionary = {
math: 95,
science: 87
};
Class Best Practices
// ✅ Use access modifiers
class User {
private id: string;
protected name: string;
public email: string;
// ✅ Readonly properties
readonly createdAt: Date;
// ✅ Parameter properties (shorthand)
constructor(
id: string,
private password: string,
public readonly username: string
) {
this.id = id;
this.name = username;
this.email = '';
this.createdAt = new Date();
}
// ✅ Explicit return types
getId(): string {
return this.id;
}
// ✅ Use protected for inheritance
protected setName(name: string): void {
this.name = name;
}
}
// ✅ Abstract classes for base classes
abstract class BaseRepository<T> {
protected items: T[] = [];
abstract validate(item: T): boolean;
save(item: T): void {
if (!this.validate(item)) {
throw new Error('Invalid item');
}
this.items.push(item);
}
}
class UserRepository extends BaseRepository<User> {
validate(user: User): boolean {
return user.email.includes('@');
}
}
ESLint Configuration
// .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": {
// Enforce explicit return types
"@typescript-eslint/explicit-function-return-type": "warn",
// Prevent any usage
"@typescript-eslint/no-explicit-any": "error",
// Prevent unused variables
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
// Require consistent type imports
"@typescript-eslint/consistent-type-imports": "error",
// Enforce naming conventions
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "interface",
"format": ["PascalCase"]
},
{
"selector": "typeAlias",
"format": ["PascalCase"]
},
{
"selector": "variable",
"modifiers": ["const"],
"format": ["camelCase", "UPPER_CASE"]
}
],
// Prevent floating promises
"@typescript-eslint/no-floating-promises": "error",
// Require array type syntax consistency
"@typescript-eslint/array-type": ["error", {
"default": "array-simple"
}]
}
}
Common Mistakes to Avoid
// ❌ Mistake 1: Optional chaining with side effects
const user = getUser();
user?.updateLastLogin(); // Returns undefined if user is null, no error!
// ✅ Fix: Explicit check when side effect matters
if (user) {
user.updateLastLogin();
}
// ❌ Mistake 2: Using ! (non-null assertion) unsafely
function processUser(userId: string) {
const user = users.find(u => u.id === userId)!;
return user.name; // Crashes if user not found!
}
// ✅ Fix: Handle null case
function processUser(userId: string): string {
const user = users.find(u => u.id === userId);
if (!user) {
throw new Error('User not found');
}
return user.name;
}
// ❌ Mistake 3: Widening types unnecessarily
let status = 'pending'; // Type: string (too wide)
status = 'invalid'; // Allowed but wrong!
// ✅ Fix: Use const or explicit type
const status = 'pending'; // Type: 'pending' (literal)
let status: 'pending' | 'approved' | 'rejected' = 'pending';
// ❌ Mistake 4: Mutating readonly arrays
function addItem(items: readonly string[], item: string) {
items.push(item); // Error: readonly
}
// ✅ Fix: Return new array
function addItem(items: readonly string[], item: string): string[] {
return [...items, item];
}
// ❌ Mistake 5: Type guards that don't narrow correctly
function isString(value: unknown) {
return typeof value === 'string'; // Returns boolean, doesn't narrow type
}
// ✅ Fix: Use proper type predicate
function isString(value: unknown): value is string {
return typeof value === 'string';
}
Import/Export Best Practices
// ✅ Use type imports for types
import type { User, Admin } from './types';
import { createUser } from './user';
// ✅ Separate type and value exports
export type { User, Admin };
export { createUser, updateUser };
// ✅ Use barrel exports carefully (can impact bundle size)
// index.ts
export * from './user';
export * from './admin';
export type * from './types'; // Only export types
// ❌ Avoid circular dependencies
// file1.ts
import { funcB } from './file2';
export function funcA() { funcB(); }
// file2.ts
import { funcA } from './file1'; // Circular!
export function funcB() { funcA(); }
Exercise: Refactor code to follow best practices:
- Enable strict mode in an existing TypeScript project
- Replace all
anytypes with proper types orunknown - Add explicit return types to all functions
- Implement proper null/undefined handling with optional chaining
- Add type guards for all external data validation
- Configure ESLint with TypeScript rules
- Fix all linting errors and warnings
Summary
In this lesson, you learned TypeScript best practices for writing maintainable, type-safe code. We covered strict mode, avoiding any, proper null handling, type guards, function typing, class patterns, ESLint configuration, and common mistakes. Following these practices will help you write robust TypeScript applications.