Why TypeScript?
TypeScript has become the de facto standard for building large-scale JavaScript applications. It adds static typing to JavaScript, catching errors at compile time rather than runtime, and providing excellent IDE support with autocomplete and refactoring tools.
Essential TypeScript Practices
1. Use Strict Mode
Always enable strict mode in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true
}
}
This catches many potential bugs and enforces better type safety.
2. Avoid any Type
The any type defeats the purpose of TypeScript. Instead:
// Bad
function processData(data: any) {
return data.value;
}
// Good
interface Data {
value: string;
}
function processData(data: Data) {
return data.value;
}
// Better - for truly unknown types
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as Data).value;
}
throw new Error('Invalid data');
}
3. Leverage Type Inference
TypeScript’s type inference is powerful—use it:
// Don't need to specify types here
const numbers = [1, 2, 3]; // TypeScript infers: number[]
const user = { name: 'John', age: 30 }; // inferred type
// Do specify when inference isn't clear
const config: Config = loadConfig();
Advanced Type Patterns
Utility Types
TypeScript provides built-in utility types:
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Partial - all properties optional
type PartialUser = Partial<User>;
// Pick - select specific properties
type UserPreview = Pick<User, 'name' | 'email'>;
// Omit - exclude properties
type UserWithoutId = Omit<User, 'id'>;
// Readonly - make immutable
type ImmutableUser = Readonly<User>;
// Record - create object type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
Discriminated Unions
Perfect for handling different states:
type LoadingState = {
status: 'loading';
};
type SuccessState = {
status: 'success';
data: User[];
};
type ErrorState = {
status: 'error';
error: Error;
};
type ApiState = LoadingState | SuccessState | ErrorState;
function handleState(state: ApiState) {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return state.data; // TypeScript knows data exists
case 'error':
return state.error.message; // TypeScript knows error exists
}
}
Generic Constraints
Make generics more useful:
// Without constraints
function getProperty<T>(obj: T, key: string) {
return obj[key]; // Error: Element implicitly has 'any' type
}
// With constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // Type-safe!
}
const user = { name: 'John', age: 30 };
const name = getProperty(user, 'name'); // TypeScript knows it's a string
Structuring Types
Interface vs Type
Both work, but have different use cases:
// Interfaces - better for objects, can be extended
interface User {
name: string;
email: string;
}
interface Admin extends User {
permissions: string[];
}
// Types - better for unions, intersections, primitives
type ID = string | number;
type ApiResponse<T> = { data: T } | { error: string };
// Use interfaces for public APIs
// Use types for complex type manipulations
Organize with Namespaces
For large type definitions:
namespace API {
export interface User {
id: string;
name: string;
}
export interface Post {
id: string;
title: string;
authorId: User['id'];
}
export type Response<T> = {
success: true;
data: T;
} | {
success: false;
error: string;
};
}
// Usage
const user: API.User = { id: '1', name: 'John' };
const response: API.Response<API.User> = { success: true, data: user };
Type Guards
Create runtime type checking:
// Type predicate
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'email' in obj
);
}
// Usage
function processData(data: unknown) {
if (isUser(data)) {
console.log(data.name); // TypeScript knows it's a User
}
}
// Built-in type guards
typeof value === 'string'
value instanceof Date
Array.isArray(value)
'property' in object
Async/Await with Types
Handle promises correctly:
// Type async functions
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data as User;
}
// Handle errors with proper types
async function safeFetchUser(id: string): Promise<User | null> {
try {
return await fetchUser(id);
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
return null;
}
}
Configuration Best Practices
Optimize your tsconfig.json:
{
"compilerOptions": {
// Type Checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
// Modules
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
// Emit
"declaration": true,
"sourceMap": true,
"removeComments": false,
// Interop
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
// Language
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
Testing with TypeScript
Type-safe tests:
import { describe, it, expect } from 'vitest';
describe('User Service', () => {
it('should create a user', () => {
const user: User = {
id: '1',
name: 'John',
email: 'john@example.com',
age: 30
};
expect(user.name).toBe('John');
expect(user.email).toContain('@');
});
it('should validate user data', () => {
const invalidUser = { name: 'John' }; // Missing required fields
expect(() => {
validateUser(invalidUser as User); // Type assertion needed
}).toThrow();
});
});
Common Pitfalls to Avoid
1. Type Assertions Overuse
// Bad - overriding type system
const user = data as User;
// Good - validate first
function isUser(data: unknown): data is User {
// validation logic
}
const user = isUser(data) ? data : null;
2. Optional Chaining Misuse
// Risky - might hide bugs
const name = user?.profile?.name;
// Better - be explicit about optionality
type User = {
profile?: {
name: string;
};
};
3. Ignoring Null/Undefined
// Bad
function getLength(str: string | null) {
return str.length; // Error if null
}
// Good
function getLength(str: string | null) {
return str?.length ?? 0;
}
Performance Tips
-
Use
constassertions for literal types:const routes = ['home', 'about', 'contact'] as const; type Route = typeof routes[number]; // 'home' | 'about' | 'contact' -
Lazy-load types for large applications:
type LazyUser = () => Promise<typeof import('./user').User>; -
Use project references for monorepos:
{ "references": [ { "path": "./packages/core" }, { "path": "./packages/ui" } ] }
Conclusion
TypeScript is more than just adding types to JavaScript—it’s about building more maintainable, self-documenting code. By following these practices:
- Use strict mode and avoid
any - Leverage TypeScript’s advanced features
- Create proper type guards
- Structure types logically
- Configure your project correctly
You’ll create applications that are easier to maintain, refactor, and scale. The initial investment in learning TypeScript pays dividends as your codebase grows.
Start small, gradually adopt more advanced patterns, and let TypeScript help you catch bugs before they reach production!