Why TypeScript?
TypeScript catches errors at compile time, provides better IDE support, and makes refactoring safer. After working on large codebases in both JavaScript and TypeScript, the difference is night and day.
When TypeScript shines:
- Large codebases with multiple contributors
- APIs with complex data structures
- Applications that need to scale
- Teams that value maintainability
When plain JavaScript might be enough:
- Small scripts or prototypes
- Projects with minimal logic
- Solo projects where you control everything
Type Annotations: Be Explicit Where It Matters
Function Parameters and Return Types
// ✅ Good: Clear contract
function calculateTotal(price: number, quantity: number): number {
return price * quantity;
}
// ❌ Bad: Implicit any
function calculateTotal(price, quantity) {
return price * quantity;
}
When to Let TypeScript Infer
// ✅ Good: Inference works well
const total = calculateTotal(10, 5); // inferred as number
const items = [1, 2, 3]; // inferred as number[]
// ❌ Unnecessary: Over-annotation
const total: number = calculateTotal(10, 5);
const items: number[] = [1, 2, 3];
Rule of thumb: Annotate function signatures, let TypeScript infer variables.
Interfaces vs Types: Know the Difference
Use Interfaces for Objects
// ✅ Interfaces for object shapes
interface User {
id: string;
name: string;
email: string;
}
interface AdminUser extends User {
permissions: string[];
}
Use Types for Unions and Complex Types
// ✅ Types for unions
type Status = 'pending' | 'active' | 'inactive';
type Result = Success | Error;
// ✅ Types for computed properties
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;
They’re Often Interchangeable
// Both work for object shapes
interface Point { x: number; y: number; }
type Point = { x: number; y: number };
// Key difference: Interfaces can be reopened
interface Window {
customProperty: string;
}
// Types cannot be reopened
type Window = { /* can't add to this later */ };
My preference: Interfaces for public APIs, types for everything else.
Avoid the any Escape Hatch
When You’re Tempted to Use any
// ❌ Defeats the purpose of TypeScript
function processData(data: any) {
return data.map((item: any) => item.value);
}
// ✅ Use unknown for truly unknown data
function processData(data: unknown) {
if (Array.isArray(data)) {
return data.map(item => {
if (typeof item === 'object' && item !== null && 'value' in item) {
return item.value;
}
return null;
});
}
throw new Error('Invalid data');
}
// ✅ Or use generics
function processData<T extends { value: unknown }>(data: T[]) {
return data.map(item => item.value);
}
The unknown Type is Your Friend
// unknown forces you to check types
function parseJSON(json: string): unknown {
return JSON.parse(json);
}
const data = parseJSON('{"name": "John"}');
// ❌ Error: Object is of type 'unknown'
console.log(data.name);
// ✅ Type guard required
if (typeof data === 'object' && data !== null && 'name' in data) {
console.log(data.name);
}
Generics: Write Reusable, Type-Safe Code
Basic Generic Function
// ✅ Generic preserves type information
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = firstElement([1, 2, 3]); // number | undefined
const str = firstElement(['a', 'b']); // string | undefined
Generic Constraints
// Ensure T has a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('hello', 'hi'); // ✅ string
longest([1, 2], [1, 2, 3]); // ✅ number[]
longest(10, 20); // ❌ Error: number doesn't have length
Generic Utility Functions
// API response wrapper
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: String(error) };
}
}
// Usage with type narrowing
const result = await fetchUser('123');
if (result.success) {
console.log(result.data.name); // ✅ data is User
} else {
console.log(result.error); // ✅ error is string
}
Type Guards: Narrow Types Safely
Built-in Type Guards
function processValue(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase(); // ✅ value is string
}
return value.toFixed(2); // ✅ value is number
}
Custom Type Guards
interface Dog {
bark(): void;
}
interface Cat {
meow(): void;
}
// Type predicate
function isDog(animal: Dog | Cat): animal is Dog {
return 'bark' in animal;
}
function makeSound(animal: Dog | Cat) {
if (isDog(animal)) {
animal.bark(); // ✅ TypeScript knows it's a Dog
} else {
animal.meow(); // ✅ TypeScript knows it's a Cat
}
}
Discriminated Unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'square'; size: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'square':
return shape.size ** 2;
}
}
Utility Types: Don’t Reinvent the Wheel
Partial and Required
interface User {
id: string;
name: string;
email: string;
}
// Make all properties optional
type UserUpdate = Partial<User>;
function updateUser(id: string, updates: UserUpdate) {
// updates can have any subset of User properties
}
// Make all properties required
type CompleteUser = Required<UserUpdate>;
Pick and Omit
// Select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// Exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;
// Useful for API responses
type PublicUser = Omit<User, 'email' | 'password'>;
Record for Key-Value Maps
// Instead of index signature
type UserMap = Record<string, User>;
const users: UserMap = {
'user1': { id: '1', name: 'John', email: 'john@example.com' },
'user2': { id: '2', name: 'Jane', email: 'jane@example.com' },
};
// With specific keys
type Permissions = Record<'read' | 'write' | 'delete', boolean>;
const permissions: Permissions = {
read: true,
write: true,
delete: false,
};
ReturnType and Parameters
function createUser(name: string, email: string) {
return { id: crypto.randomUUID(), name, email };
}
// Extract return type
type User = ReturnType<typeof createUser>;
// Extract parameter types
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string]
Async TypeScript
Typing Promises
// ✅ Explicit Promise type
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// ✅ With error handling
async function fetchUserSafe(
id: string
): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
Typing Event Handlers
// React example
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
console.log(event.currentTarget.name);
}
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
console.log(event.target.value);
}
// Generic DOM
function handleDOMClick(event: MouseEvent) {
if (event.target instanceof HTMLElement) {
console.log(event.target.tagName);
}
}
Strict Mode Configuration
Essential tsconfig.json Settings
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
Start strict, stay strict. It’s harder to add strict mode later.
Common Patterns
Builder Pattern with Types
class QueryBuilder<T> {
private filters: Array<(item: T) => boolean> = [];
where(predicate: (item: T) => boolean): this {
this.filters.push(predicate);
return this;
}
execute(items: T[]): T[] {
return items.filter(item =>
this.filters.every(filter => filter(item))
);
}
}
// Usage
const result = new QueryBuilder<User>()
.where(u => u.age > 18)
.where(u => u.email.includes('@'))
.execute(users);
Branded Types for IDs
// Prevent ID confusion
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function createPostId(id: string): PostId {
return id as PostId;
}
function getUser(id: UserId): User { /* ... */ }
function getPost(id: PostId): Post { /* ... */ }
const userId = createUserId('123');
const postId = createPostId('456');
getUser(userId); // ✅
getUser(postId); // ❌ Error: Type mismatch
Const Assertions
// Without const assertion
const colors = ['red', 'green', 'blue'];
// Type: string[]
// With const assertion
const colors = ['red', 'green', 'blue'] as const;
// Type: readonly ['red', 'green', 'blue']
type Color = typeof colors[number];
// Type: 'red' | 'green' | 'blue'
Error Handling
Result Type Pattern
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return { ok: false, error: new Error('Division by zero') };
}
return { ok: true, value: a / b };
}
// Usage
const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // ✅ value is number
} else {
console.error(result.error); // ✅ error is Error
}
Type-Safe Error Classes
class ValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly code: string
) {
super(message);
this.name = 'ValidationError';
}
}
function validateEmail(email: string): void {
if (!email.includes('@')) {
throw new ValidationError(
'Invalid email format',
'email',
'INVALID_FORMAT'
);
}
}
// Usage
try {
validateEmail('invalid');
} catch (error) {
if (error instanceof ValidationError) {
console.log(`${error.field}: ${error.message}`);
}
}
Performance Tips
Avoid Expensive Type Computations
// ❌ Slow: Deep recursive type
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
// ✅ Better: Limit recursion or use built-in types
type ShallowPartial<T> = Partial<T>;
Use Type Inference
// ❌ Explicit generic often unnecessary
const items = new Array<number>();
// ✅ Let TypeScript infer
const items: number[] = [];
Testing with TypeScript
Type-Safe Mocks
// Mock factory
function createMockUser(overrides?: Partial<User>): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
...overrides,
};
}
// Usage in tests
const user = createMockUser({ name: 'John' });
expect(user.name).toBe('John');
Type-Safe Test Utilities
function expectType<T>(value: T): void {}
// Compile-time type checks
expectType<number>(123); // ✅
expectType<number>('123'); // ❌ Compile error
Migration Strategy
Gradual Adoption
// tsconfig.json for gradual migration
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noImplicitAny": false
}
}
File-by-File Migration
- Rename
.jsto.ts - Add
// @ts-checkto JavaScript files - Fix immediate errors
- Enable stricter checks gradually
- Remove any types over time
Common Mistakes to Avoid
1. Type Assertions Gone Wild
// ❌ Dangerous: Bypasses type checking
const user = data as User;
// ✅ Better: Validate at runtime
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data
);
}
if (isUser(data)) {
// data is User
}
2. Optional Chaining Overuse
// ❌ Hides real issues
user?.profile?.settings?.theme?.color;
// ✅ Better: Model data correctly
interface User {
profile: {
settings: {
theme: { color: string };
};
};
}
3. Enum Misuse
// ❌ Enums have runtime cost
enum Status {
Active,
Inactive,
}
// ✅ Use union types
type Status = 'active' | 'inactive';
Best Practices Summary
- Enable strict mode from day one
- Avoid
any, useunknowninstead - Let TypeScript infer where it can
- Use discriminated unions for complex types
- Prefer interfaces for object shapes
- Write custom type guards for runtime validation
- Use utility types instead of manual mapping
- Type function signatures, not every variable
- Keep types close to where they’re used
- Test types as part of your test suite
Next Steps
- Learn advanced type manipulation (mapped types, conditional types)
- Explore template literal types for string validation
- Study decorator types for metadata
- Practice type-level programming
- Read the TypeScript Handbook
