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: truealong 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) {} // OKnoUncheckedIndexedAccess: 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 stringConclusion: 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
- Enable
strict: true - Eliminate implicit
any - Handle
nullandundefinedrigorously
Phase 2: Leverage the Type System
- Use utility types to streamline type definitions
- Add type guards for runtime safety
- Use generics to build reusable types
Phase 3: Advanced Type Safety
- Validate external data with Zod
- Make error handling type-safe with the Result pattern
- 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.