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:
{
"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:
// 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 - 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
Generic constraints limit what types are acceptable:
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
Utility Types You Should Know
TypeScript includes utility types that transform existing types. Master these:
Partial<T> makes all properties optional:
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:
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:
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:
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:
// 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:
// 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);
}
}
Type assertions without validation: as assertions tell TypeScript to trust you. Validate first:
// 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
}
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:
{
"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:
/**
* @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.
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.