TypeScript Best Practices: Type-Safe Programming Experience
TypeScript has become the standard for building large-scale JavaScript applications, providing type safety and better developer experience. As you build applications with TypeScript, following best practices ensures your code is maintainable, scalable, and less prone to errors. This guide covers essential TypeScript best practices that we've learned while building ContentQR, a complex application handling QR code generation, content management, and AI-powered features. You'll learn how to leverage TypeScript's type system effectively, avoid common pitfalls, and write code that catches errors at compile time rather than runtime. Whether you're new to TypeScript or looking to improve your existing codebase, these practices will help you write better, more reliable code.
Type Safety Fundamentals
Use Strict Mode
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
Benefits:
- Catches more errors at compile time
- Prevents common JavaScript pitfalls
- Improves code quality
Avoid any Type
The any type defeats the purpose of TypeScript:
// ❌ Bad: Using any
function processData(data: any) {
return data.value * 2; // No type safety
}
// ✅ Good: Use specific types
function processData(data: { value: number }) {
return data.value * 2; // Type-safe
}
// ✅ Better: Use generics
function processData<T extends { value: number }>(data: T): T {
return { ...data, value: data.value * 2 };
}
Use unknown Instead of any
When you need to handle unknown types, use unknown:
// ❌ Bad: any bypasses type checking
function parseJSON(json: string): any {
return JSON.parse(json);
}
// ✅ Good: unknown requires type checking
function parseJSON<T>(json: string): T {
return JSON.parse(json) as T;
}
// ✅ Better: Type guard
function isUserData(data: unknown): data is UserData {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data
);
}
function parseJSON(json: string): UserData {
const parsed = JSON.parse(json);
if (isUserData(parsed)) {
return parsed;
}
throw new Error('Invalid user data');
}
Type Definitions
Prefer Interfaces for Object Shapes
Use interfaces for object shapes, especially when extending:
// ✅ Good: Interface for object shape
interface User {
id: string;
name: string;
email: string;
}
interface AdminUser extends User {
role: 'admin';
permissions: string[];
}
Use Types for Unions and Intersections
Use types for unions, intersections, and computed types:
// ✅ Good: Type for union
type Status = 'pending' | 'approved' | 'rejected';
// ✅ Good: Type for intersection
type AdminUser = User & {
role: 'admin';
permissions: string[];
};
// ✅ Good: Type for computed types
type UserKeys = keyof User; // 'id' | 'name' | 'email'
Real-World Example: ContentQR Types
In ContentQR, we define clear types for our domain:
// User types
interface User {
id: string;
email: string;
name: string;
avatar?: string;
createdAt: Date;
}
interface QRCode {
id: string;
userId: string;
title: string;
content: string;
type: QRCodeType;
createdAt: Date;
updatedAt: Date;
}
type QRCodeType = 'url' | 'text' | 'email' | 'wifi' | 'vcard';
// API response types
interface ApiResponse<T> {
data: T;
error?: string;
success: boolean;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
Function Types
Explicit Return Types
Always specify return types for functions:
// ❌ Bad: Implicit return type
function getUser(id: string) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
// ✅ Good: Explicit return type
function getUser(id: string): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
// ✅ Better: Async function
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
Function Overloads
Use function overloads for multiple signatures:
// Function overloads
function getQRCode(id: string): Promise<QRCode>;
function getQRCode(userId: string, title: string): Promise<QRCode[]>;
function getQRCode(idOrUserId: string, title?: string): Promise<QRCode | QRCode[]> {
if (title) {
return fetch(`/api/users/${idOrUserId}/qrcodes?title=${title}`).then(r => r.json());
}
return fetch(`/api/qrcodes/${idOrUserId}`).then(r => r.json());
}
Optional Parameters
Use optional parameters and default values correctly:
// ✅ Good: Optional parameter with default
function createQRCode(
content: string,
options?: {
title?: string;
type?: QRCodeType;
}
): QRCode {
return {
id: generateId(),
content,
title: options?.title ?? 'Untitled',
type: options?.type ?? 'url',
// ...
};
}
// ✅ Better: Destructured with defaults
function createQRCode(
content: string,
{
title = 'Untitled',
type = 'url',
}: {
title?: string;
type?: QRCodeType;
} = {}
): QRCode {
return {
id: generateId(),
content,
title,
type,
// ...
};
}
Generics
Use Generics for Reusable Code
Generics make code reusable and type-safe:
// ✅ Good: Generic function
function getById<T extends { id: string }>(
items: T[],
id: string
): T | undefined {
return items.find(item => item.id === id);
}
// Usage
const user = getById(users, '123');
const qrCode = getById(qrCodes, '456');
Generic Constraints
Use constraints to limit generic types:
// ✅ Good: Constrained generic
function updateEntity<T extends { id: string; updatedAt: Date }>(
entity: T,
updates: Partial<Omit<T, 'id' | 'updatedAt'>>
): T {
return {
...entity,
...updates,
updatedAt: new Date(),
};
}
For more advanced generic patterns, check out our guide on Advanced Type Handling.
Error Handling
Use Result Types Instead of Throwing
Prefer result types for better error handling:
// ✅ Good: Result type
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function getUser(id: string): Promise<Result<User>> {
try {
const user = await fetchUser(id);
return { success: true, data: user };
} catch (error) {
return { success: false, error: error as Error };
}
}
// Usage
const result = await getUser('123');
if (result.success) {
console.log(result.data.name);
} else {
console.error(result.error.message);
}
Custom Error Types
Create custom error types:
class ValidationError extends Error {
constructor(
public field: string,
public message: string
) {
super(message);
this.name = 'ValidationError';
}
}
class NotFoundError extends Error {
constructor(public resource: string, public id: string) {
super(`${resource} with id ${id} not found`);
this.name = 'NotFoundError';
}
}
function getUser(id: string): Promise<User> {
if (!id) {
throw new ValidationError('id', 'ID is required');
}
// ...
}
Utility Types
Use Built-in Utility Types
TypeScript provides useful utility types:
// Partial: Make all properties optional
type PartialUser = Partial<User>;
// Required: Make all properties required
type RequiredUser = Required<User>;
// Pick: Select specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
// Omit: Exclude specific properties
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// Record: Create object type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
// Readonly: Make properties readonly
type ImmutableUser = Readonly<User>;
Real-World Example: API Types
In ContentQR, we use utility types for API requests:
// Create input (omit id and timestamps)
type CreateQRCodeInput = Omit<QRCode, 'id' | 'createdAt' | 'updatedAt'>;
// Update input (partial, but require id)
type UpdateQRCodeInput = Pick<QRCode, 'id'> & Partial<Omit<QRCode, 'id'>>;
// API response wrapper
type ApiResponse<T> = {
data: T;
success: true;
} | {
error: string;
success: false;
};
Module Organization
Use Barrel Exports
Organize exports with barrel files:
// types/index.ts
export type { User, QRCode, QRCodeType } from './user';
export type { ApiResponse, PaginatedResponse } from './api';
// Usage
import type { User, QRCode } from '@/types';
Separate Types and Values
Keep types and values separate:
// types/user.ts
export interface User {
id: string;
name: string;
}
// constants/user.ts
export const DEFAULT_USER: User = {
id: '',
name: 'Guest',
};
Best Practices Summary
1. Enable Strict Mode
Always use strict mode for better type safety.
2. Avoid any
Use unknown when you need to handle unknown types, then narrow with type guards.
3. Use Explicit Types
Specify return types and use type annotations for clarity.
4. Leverage Utility Types
Use built-in utility types like Partial, Pick, Omit for common transformations.
5. Use Generics
Create reusable, type-safe code with generics and constraints.
6. Organize Types
Keep types organized in separate files and use barrel exports.
7. Handle Errors Properly
Use result types or custom error classes instead of throwing everywhere.
8. Use Type Guards
Create type guards for runtime type checking:
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data
);
}
Common Pitfalls
1. Type Assertions
Avoid unnecessary type assertions:
// ❌ Bad: Unsafe assertion
const user = data as User;
// ✅ Good: Type guard
if (isUser(data)) {
const user = data; // Type is User
}
2. Non-null Assertions
Avoid non-null assertions unless absolutely necessary:
// ❌ Bad: Non-null assertion
const name = user.name!;
// ✅ Good: Null check
if (user.name) {
const name = user.name;
}
3. Empty Object Types
Avoid empty object types:
// ❌ Bad: Empty object
type Config = {};
// ✅ Good: Specific properties or Record
type Config = Record<string, unknown>;
// or
type Config = {
[key: string]: unknown;
};
Conclusion
TypeScript best practices help you write maintainable, type-safe code that catches errors at compile time. By following these practices—enabling strict mode, avoiding any, using explicit types, leveraging utility types, and organizing your code properly—you'll build more reliable applications. In ContentQR, these practices have been essential for managing complex types across QR code generation, content management, and AI features.
Key Takeaways:
- Always enable strict mode for maximum type safety
- Avoid
anyand useunknownwith type guards instead - Use explicit return types and type annotations for clarity
- Leverage utility types (
Partial,Pick,Omit) for common transformations - Use generics for reusable, type-safe code
- Organize types in separate files with barrel exports
- Handle errors with result types or custom error classes
- Create type guards for runtime type checking
Next Steps:
- Review your
tsconfig.jsonand enable strict mode if not already enabled - Audit your codebase for
anytypes and replace them with proper types - Refactor functions to have explicit return types
- Learn about Advanced Type Handling for complex generic patterns
- Consider using TypeScript best practices in your Next.js projects
- Set up ESLint rules to enforce TypeScript best practices
Related Posts
ContentQR Full-Stack Architecture Evolution: From Monolith to Modular Design
Learn how to evolve your architecture from monolith to modular design. Practical insights and lessons learned from real-world experience.
Advanced Type Handling: Generics and Utility Types Usage Tips
Master advanced TypeScript type handling with generics and utility types. Learn practical tips and patterns for complex type scenarios.
QR Code Best Practices: Design and Usage Tips
Essential QR code best practices for design and usage. Learn how to create effective QR codes that get scanned and drive results.