FRONTEND2024-09-12📖 3 min read

TypeScript Best Practices 2024: Practical Techniques for Type Safety and Developer Productivity

TypeScript Best Practices 2024: Practical Techniques for Type Safety and Developer Productivity

How to maximize development efficiency without sacrificing type safety. A deep dive into practical patterns covering Strict Mode, type guards, utility types, and generics.

髙木 晃宏

代表 / エンジニア

👨‍💼

TL;DR

  • Strict Mode is non-negotiable. Enable strict: true along with additional compiler options.
  • Use type guards to ensure runtime safety while leveraging compile-time type narrowing.
  • Utility types eliminate duplication and improve maintainability.
  • Use Zod to automate validation and type inference for external data.

Why TypeScript?

"JavaScript works fine — why bother with TypeScript?"

Our answer is simple: TypeScript prevents up to 70% of bugs.

According to Microsoft research, roughly 15% of bugs in JavaScript projects stem from type-related errors. Factor in bugs caused by misunderstandings due to unclear types, and that number climbs significantly.

Our team migrated a legacy JavaScript codebase to TypeScript in 2022. The results:

  • Production bugs dropped by 42%
  • Code review time cut by 30%
  • Onboarding time for new team members cut in half

This article walks through the TypeScript best practices behind those outcomes, with practical code examples throughout.

The Complete Guide to Strict Mode

Basic Configuration

TypeScript's Strict Mode is a collection of settings that enforce rigorous type checking. Always enable it for new projects.

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

What Each Option Does

Options enabled by strict: true

// strictNullChecks: enforces strict null/undefined handling function getLength(str: string | null) { // Error: str might be null // return str.length; // Correct implementation return str?.length ?? 0; } // strictFunctionTypes: enforces strict checking on function arguments type Handler = (event: MouseEvent) => void; const handler: Handler = (event: Event) => {}; // Error! // strictBindCallApply: checks arguments to bind/call/apply function greet(name: string) { return `Hello, ${name}`; } greet.call(null, 123); // Error: number is not assignable to string // noImplicitAny: disallows implicit any types function process(data) {} // Error: parameter 'data' implicitly has an 'any' type function process(data: unknown) {} // OK

noUncheckedIndexedAccess: Safer Array Access

// Without this option, array access can be misleading const items = ['a', 'b', 'c']; const item = items[10]; // typed as string (but actually undefined) // With noUncheckedIndexedAccess: true const item = items[10]; // typed as string | undefined if (item) { console.log(item.toUpperCase()); // safe }

exactOptionalPropertyTypes: Stricter Optional Properties

interface Config { timeout?: number; } // With exactOptionalPropertyTypes: false const config: Config = { timeout: undefined }; // OK // With exactOptionalPropertyTypes: true const config: Config = { timeout: undefined }; // Error! const config: Config = {}; // OK (omit the property entirely)

Practical Type Guard Patterns

Type guards communicate runtime checks to the TypeScript compiler, enabling automatic type narrowing within conditional branches.

Built-in Type Guards

// typeof type guard function processValue(value: string | number) { if (typeof value === 'string') { // value is string here return value.toUpperCase(); } // value is number here return value.toFixed(2); } // instanceof type guard function handleError(error: Error | string) { if (error instanceof Error) { return error.message; } return error; } // in operator type guard interface Bird { fly(): void; } interface Fish { swim(): void; } function move(animal: Bird | Fish) { if ('fly' in animal) { animal.fly(); } else { animal.swim(); } }

Custom Type Guards (Type Predicates)

// User-defined type guard interface User { id: string; name: string; email: string; } interface AdminUser extends User { role: 'admin'; permissions: string[]; } // Type guard using a type predicate function isAdminUser(user: User): user is AdminUser { return 'role' in user && (user as AdminUser).role === 'admin'; } function handleUser(user: User) { if (isAdminUser(user)) { // user is now treated as AdminUser console.log(user.permissions); } }

Array Filtering with Type Guards

// Type guard to filter out null/undefined function isNotNull<T>(value: T | null | undefined): value is T { return value !== null && value !== undefined; } const items: (string | null)[] = ['a', null, 'b', null, 'c']; const filtered: string[] = items.filter(isNotNull); // filtered is string[] — nulls are gone // Extract only specific subtypes type ApiResponse = | { type: 'success'; data: string } | { type: 'error'; message: string }; function isSuccess(response: ApiResponse): response is { type: 'success'; data: string } { return response.type === 'success'; } const responses: ApiResponse[] = [ { type: 'success', data: 'hello' }, { type: 'error', message: 'failed' }, { type: 'success', data: 'world' }, ]; const successResponses = responses.filter(isSuccess); // successResponses is { type: 'success'; data: string }[]

Working with Utility Types

TypeScript ships with powerful built-in utility types. Leveraging them eliminates type duplication and keeps your codebase maintainable.

Core Utility Types

interface User { id: string; name: string; email: string; createdAt: Date; updatedAt: Date; } // Partial: make all properties optional type UpdateUserInput = Partial<User>; // { id?: string; name?: string; email?: string; ... } // Required: make all properties required type StrictUser = Required<User>; // Pick: extract specific properties type UserProfile = Pick<User, 'id' | 'name' | 'email'>; // { id: string; name: string; email: string } // Omit: exclude specific properties type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>; // { name: string; email: string } // Readonly: make all properties read-only type ImmutableUser = Readonly<User>;

Composing Utility Types

// Defining types around an API response shape interface ApiUser { id: string; name: string; email: string; password: string; // internal only createdAt: string; // string in API responses } // Public-facing user type (no password) type PublicUser = Omit<ApiUser, 'password'>; // Update DTO (id and createdAt are immutable) type UpdateUserDto = Partial<Omit<PublicUser, 'id' | 'createdAt'>>; // Form data type (all fields required, no id or createdAt) type UserFormData = Required<Omit<PublicUser, 'id' | 'createdAt'>>;

Using the Record Type

// Object type with a fixed set of keys type Status = 'pending' | 'active' | 'inactive'; type StatusLabels = Record<Status, string>; const statusLabels: StatusLabels = { pending: 'Pending', active: 'Active', inactive: 'Inactive', }; // Object type with dynamic keys type UserCache = Record<string, User | undefined>; const cache: UserCache = {}; cache['user-1'] = { id: '1', name: 'Alice', email: 'alice@example.com', createdAt: new Date(), updatedAt: new Date() }; // Account for possible undefined when reading const user = cache['user-1']; if (user) { console.log(user.name); }

Practical Generics Patterns

Generics are a powerful tool for building reusable, type-safe abstractions.

Basic Generics

// A generic API response wrapper interface ApiResponse<T> { data: T; status: number; message: string; } // Usage type UserResponse = ApiResponse<User>; type UsersResponse = ApiResponse<User[]>; async function fetchUser(id: string): Promise<ApiResponse<User>> { const response = await fetch(`/api/users/${id}`); return response.json(); }

Constrained Generics

// Constrain to object types function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: 'Alice', age: 30 }; const name = getProperty(user, 'name'); // string const age = getProperty(user, 'age'); // number // getProperty(user, 'email'); // Error: 'email' does not exist in keyof user // Constrain to types with a specific property interface Identifiable { id: string; } function findById<T extends Identifiable>(items: T[], id: string): T | undefined { return items.find(item => item.id === id); }

Conditional Types

// Roll your own NonNullable type MyNonNullable<T> = T extends null | undefined ? never : T; // Unwrap a Promise type Awaited<T> = T extends Promise<infer U> ? U : T; type A = Awaited<Promise<string>>; // string type B = Awaited<string>; // string // Extract a function's return type type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never; function createUser() { return { id: '1', name: 'Alice' }; } type CreatedUser = ReturnTypeOf<typeof createUser>; // { id: string; name: string }

Type-Safe Error Handling

The Result Type Pattern

Instead of throwing exceptions, represent success and failure as types.

// Result type definition type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; // Example usage async function fetchUserSafe(id: string): Promise<Result<User>> { try { const response = await fetch(`/api/users/${id}`); if (!response.ok) { return { success: false, error: new Error(`HTTP ${response.status}`) }; } const data = await response.json(); return { success: true, data }; } catch (error) { return { success: false, error: error instanceof Error ? error : new Error('Unknown error') }; } } // At the call site const result = await fetchUserSafe('123'); if (result.success) { // result.data is typed as User console.log(result.data.name); } else { // result.error is typed as Error console.error(result.error.message); }

Custom Error Types

// Represent error categories as types type ApiError = | { type: 'network'; message: string } | { type: 'validation'; fields: Record<string, string> } | { type: 'unauthorized' } | { type: 'not_found'; resource: string }; type ApiResult<T> = Result<T, ApiError>; function handleError(error: ApiError): string { switch (error.type) { case 'network': return `Network error: ${error.message}`; case 'validation': return `Validation error: ${Object.values(error.fields).join(', ')}`; case 'unauthorized': return 'Authentication required'; case 'not_found': return `${error.resource} not found`; default: // Exhaustiveness check: ensures all cases are handled const _exhaustive: never = error; return _exhaustive; } }

Validation and Type Inference with Zod

Data coming from external sources — APIs, form inputs — must be validated at runtime. Zod lets you handle validation and type inference in one step.

Basic Usage

import { z } from 'zod'; // Define a schema const userSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(100), email: z.string().email(), age: z.number().int().min(0).max(150).optional(), role: z.enum(['user', 'admin', 'moderator']), createdAt: z.string().datetime(), }); // Infer the type from the schema type User = z.infer<typeof userSchema>; // { // id: string; // name: string; // email: string; // age?: number; // role: 'user' | 'admin' | 'moderator'; // createdAt: string; // } // Strict validation (throws ZodError on failure) function parseUser(data: unknown): User { return userSchema.parse(data); } // Safe validation (returns a Result) function safeParseUser(data: unknown): Result<User> { const result = userSchema.safeParse(data); if (result.success) { return { success: true, data: result.data }; } return { success: false, error: new Error(result.error.message) }; }

Validating API Responses

// Generic API response schema const apiResponseSchema = <T extends z.ZodType>(dataSchema: T) => z.object({ data: dataSchema, status: z.number(), message: z.string(), }); const usersResponseSchema = apiResponseSchema(z.array(userSchema)); async function fetchUsers(): Promise<z.infer<typeof usersResponseSchema>> { const response = await fetch('/api/users'); const json = await response.json(); // Validation + type inference in one call return usersResponseSchema.parse(json); }

Form Validation

// Schema for a user creation form const createUserFormSchema = z.object({ name: z.string() .min(1, 'Name is required') .max(100, 'Name must be 100 characters or fewer'), email: z.string() .email('Please enter a valid email address'), password: z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Must contain at least one uppercase letter') .regex(/[0-9]/, 'Must contain at least one number'), confirmPassword: z.string(), }).refine(data => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], }); type CreateUserForm = z.infer<typeof createUserFormSchema>; // Integrating with React Hook Form import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; function CreateUserForm() { const { register, handleSubmit, formState: { errors } } = useForm<CreateUserForm>({ resolver: zodResolver(createUserFormSchema), }); const onSubmit = (data: CreateUserForm) => { // data is fully typed console.log(data.name, data.email); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name')} /> {errors.name && <span>{errors.name.message}</span>} {/* ... */} </form> ); }

Designing Around Type Inference

Leaning into TypeScript's type inference reduces verbose type annotations and improves maintainability.

Narrowing Types with as const

// Without as const const config = { apiUrl: 'https://api.example.com', timeout: 5000, }; // Type: { apiUrl: string; timeout: number } // With as const const config = { apiUrl: 'https://api.example.com', timeout: 5000, } as const; // Type: { readonly apiUrl: 'https://api.example.com'; readonly timeout: 5000 } // Works with arrays too const statuses = ['pending', 'active', 'inactive'] as const; type Status = typeof statuses[number]; // 'pending' | 'active' | 'inactive'

The satisfies Operator (TypeScript 4.9+)

// Type-check against a shape while preserving inferred literal types const routes = { home: '/', about: '/about', users: '/users', userDetail: '/users/:id', } satisfies Record<string, string>; // routes retains its specific literal types type RouteKey = keyof typeof routes; // 'home' | 'about' | 'users' | 'userDetail' const homeRoute = routes.home; // type is '/' not string

Conclusion: A Gradual Adoption Path

You don't need to apply every best practice at once. Here's a phased approach we recommend:

Phase 1: Build the Foundation

  1. Enable strict: true
  2. Eliminate implicit any
  3. Handle null and undefined rigorously

Phase 2: Leverage the Type System

  1. Use utility types to streamline type definitions
  2. Add type guards for runtime safety
  3. Use generics to build reusable types

Phase 3: Advanced Type Safety

  1. Validate external data with Zod
  2. Make error handling type-safe with the Result pattern
  3. Use Branded Types to enforce logical type distinctions

Types serve as both documentation and tests. When you design types thoughtfully, code intent becomes self-evident and bugs get caught early.

Resources


If you're planning a TypeScript migration or looking for guidance on adoption, feel free to reach out.