TypeScript Best Practices: Type-Safe Programming Experience

ContentQR Team
9 min read
Technical Development
TypeScript
Best Practices
Type Safety
Programming

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 any and use unknown with 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.json and enable strict mode if not already enabled
  • Audit your codebase for any types 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