TypeScript transforms JavaScript from a dynamic language into one with compile-time safety. But TypeScript's flexibility means you can use it poorly. This guide covers patterns that help large codebases stay maintainable.
Why Strict Mode Matters
Enable strict mode in tsconfig.json. It's not optional for serious projects. This single flag unlocks TypeScript's most valuable protections and catches entire categories of bugs before they reach production.
{
"compilerOptions": {
"strict": true
}
}
Strict mode enables several important checks: strictNullChecks catches null pointer errors, noImplicitAny prevents accidental any types, and strictFunctionTypes ensures function compatibility.
Teams that skip strict mode accumulate type debt. They end up with any scattered throughout the codebase, defeating the purpose of TypeScript.
Type Inference vs Explicit Types
TypeScript's inference is good. Let it work. Here's how to balance inference with explicit annotations for maximum clarity and safety. You'll find that knowing when to add types and when to let TypeScript figure them out is a skill that improves with experience.
// Unnecessary - TypeScript infers string
const name: string = 'John';
// Better - let inference handle it
const name = 'John';
// But explicit return types help catch errors
function getUser(id: number): User {
// TypeScript will error if this doesn't return User
}
Explicit types are valuable for function signatures and exported APIs. They serve as documentation and catch refactoring errors. For local variables, trust inference.
Generics for Reusable Code
Generics let you write functions and types that work with multiple types while maintaining type safety. Without generics, you'd either lose type information or duplicate code for each type. Think of generics as type parameters that get filled in when the function is called.
// Without generics - loses type information
function first(arr: any[]): any {
return arr[0];
}
// With generics - preserves types
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = [1, 2, 3];
const n = first(numbers); // TypeScript knows n is number | undefined
The <T> declares a type parameter that gets inferred from usage. When you call first(numbers), TypeScript sees numbers is number[] and substitutes number for T throughout the function signature.
Generic constraints limit what types are acceptable. Use extends to require that the type parameter has certain properties. This gives you the flexibility of generics while ensuring the type has the shape you need.
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
This constraint ensures you can only call findById with arrays of objects that have an id property. TypeScript will error at compile time if you pass an incompatible type, catching bugs long before they reach your users.
Utility Types You Should Know
TypeScript includes utility types that transform existing types. Master these and you'll write less boilerplate and express complex type relationships more clearly.
Partial<T> makes all properties optional. You'll use this frequently for update operations where you want to modify only some fields without requiring the entire object.
interface User {
name: string;
email: string;
}
function updateUser(id: number, updates: Partial<User>) {
// updates can have name, email, or both
}
Pick<T, K> and Omit<T, K> create types with selected properties. These are useful when you need a subset of an existing type without defining it from scratch.
type UserPreview = Pick<User, 'name'>; // { name: string }
type UserWithoutEmail = Omit<User, 'email'>; // { name: string }
Record<K, V> creates object types with specific key and value types. It's perfect for dictionaries and lookup objects where you want to constrain both keys and values.
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
const roles: UserRoles = {
'user-1': 'admin',
'user-2': 'user'
};
Required<T> makes all properties required (opposite of Partial).
Readonly<T> makes all properties read-only.
Organizing Types in Large Projects
Keep types close to where they're used. A component's props type belongs in the component file, not a central types folder.
For shared types, create a types.ts file per feature. This organization keeps related code together and makes imports intuitive.
src/
├── features/
│ ├── auth/
│ │ ├── types.ts # Auth-specific types
│ │ └── login.tsx
│ └── users/
│ ├── types.ts # User-specific types
│ └── list.tsx
└── types/
└── api.ts # Shared API response types
Export types alongside their related code. When a function returns a specific shape, export that type from the same file so consumers can import both together. This co-location makes refactoring easier and keeps your imports clean.
// user-service.ts
export interface User {
id: number;
name: string;
}
export function getUser(id: number): Promise<User> {
// implementation
}
Common Mistakes and How to Avoid Them
Using any as an escape hatch: When you don't know a type, use unknown instead. It forces you to check the type before using the value, preventing runtime errors. This small change in habit dramatically improves type safety.
// Bad - any bypasses all type checking
function process(data: any) {
data.whatever(); // No error, but might crash
}
// Better - unknown requires type checking
function process(data: unknown) {
if (typeof data === 'object' && data !== null && 'name' in data) {
console.log(data.name);
}
}
The unknown type is TypeScript's safe counterpart to any. It accepts any value but requires you to narrow the type before accessing properties or methods. You'll find yourself using type guards frequently when working with unknown.
Type assertions without validation: as assertions tell TypeScript to trust you. Validate first, especially when dealing with external data like API responses. Type assertions are a promise to the compiler that you know what you're doing, so make sure you actually do.
// Dangerous
const user = JSON.parse(response) as User;
// Safer - validate the shape
function isUser(obj: unknown): obj is User {
return typeof obj === 'object' && obj !== null && 'id' in obj && 'name' in obj;
}
const data = JSON.parse(response);
if (isUser(data)) {
// data is now typed as User
}
The type guard pattern using obj is User creates a custom narrowing function. After the if check passes, TypeScript knows the variable satisfies the User interface. You can use this pattern to safely handle any external data.
Overcomplicating types: If a type is hard to read, it's probably too complex. Break it down or reconsider your data model.
Team Conventions and Linting
Establish conventions early:
- Interfaces vs types (pick one for objects, be consistent)
- Naming conventions (
IUservsUser,UserPropsvsProps) - Where shared types live
Use ESLint with @typescript-eslint. This configuration extends the recommended rules and adds type-aware linting that catches subtle bugs that TypeScript alone might miss.
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
]
}
Key rules to enable:
@typescript-eslint/no-explicit-any- warns on any usage@typescript-eslint/no-unsafe-assignment- catches unsafe any propagation@typescript-eslint/explicit-function-return-type- enforces return types
Migration Strategies from JavaScript
Don't convert everything at once. Migrate incrementally:
- Add
tsconfig.jsonwithallowJs: true - Rename files one at a time from
.jsto.ts - Fix errors as they appear
- Add types to critical paths first (API boundaries, shared utilities)
Use JSDoc for gradual typing in JavaScript files. TypeScript can read JSDoc comments and provide type checking without requiring you to convert the file. This approach lets you get type safety benefits immediately while planning your migration.
/**
* @param {number} id
* @returns {Promise<User>}
*/
async function getUser(id) {
// TypeScript understands this
}
This lets you add type safety without renaming files or disrupting git history. It's particularly useful when you want to type-check a large JavaScript codebase before committing to a full migration. You can even enable strict null checks on JSDoc-typed files.
Conclusion
TypeScript's value comes from catching errors at compile time rather than runtime. But you only get that value if you use it properly: strict mode enabled, minimal any usage, and types that actually represent your data.
Start strict, stay strict, and let the compiler help you. The initial friction pays off in fewer production bugs and easier refactoring.