Understanding TypeScript: Beyond the Basics
Dive deep into advanced TypeScript features and learn how to leverage the type system for better code quality and developer experience.
Piotr Wislowski
Understanding TypeScript: Beyond the Basics
TypeScript has revolutionized JavaScript development by bringing static typing to the dynamic world of JavaScript. While basic types like string, number, and boolean are straightforward, TypeScript’s true power lies in its advanced type system features that can catch bugs at compile time and improve developer productivity.
Advanced Type Features
Union and Intersection Types
Union types allow a value to be one of several types:
type Status = 'loading' | 'success' | 'error';
type ID = string | number;
function handleResponse(status: Status) {
switch (status) {
case 'loading':
showSpinner();
break;
case 'success':
hideSpinner();
break;
case 'error':
showError();
break;
// TypeScript ensures all cases are handled
}
} Intersection types combine multiple types:
interface User {
name: string;
email: string;
}
interface Admin {
permissions: string[];
canDelete: boolean;
}
type AdminUser = User & Admin;
const adminUser: AdminUser = {
name: 'John Doe',
email: 'john@example.com',
permissions: ['read', 'write', 'delete'],
canDelete: true
}; Generic Types
Generics make components reusable while maintaining type safety:
interface APIResponse<T> {
data: T;
success: boolean;
message?: string;
}
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<APIResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Usage with full type safety
const userResponse = await fetchUser(123);
// userResponse.data is fully typed as User
console.log(userResponse.data.name); // ✅ TypeScript knows this exists Utility Types
TypeScript provides powerful utility types for common transformations:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Pick specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;
// Omit specific properties
type UserInput = Omit<User, 'id' | 'createdAt'>;
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
// Extract keys as union type
type UserKeys = keyof User; // 'id' | 'name' | 'email' | 'password' | 'createdAt' Advanced Patterns
Mapped Types
Create new types by transforming existing ones:
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Create a type with string keys and any values
type Record<K extends keyof any, T> = {
[P in K]: T;
};
// Usage
type UserRecord = Record<'admin' | 'user' | 'guest', User>; Conditional Types
Types that depend on conditions:
type NonNullable<T> = T extends null | undefined ? never : T;
type ApiResponse<T> = T extends string
? { message: T }
: { data: T };
// More complex example
type Flatten<T> = T extends readonly (infer U)[]
? U
: T;
type StringArray = Flatten<string[]>; // string
type NumberType = Flatten<number>; // number Template Literal Types
Create types from string patterns:
type EventName = 'click' | 'scroll' | 'mousemove';
type EventHandler<T extends string> = `on${Capitalize<T>}`;
type Handler = EventHandler<EventName>; // 'onClick' | 'onScroll' | 'onMousemove'
// More advanced usage
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type APICall = `${HTTPMethod} ${Endpoint}`;
const apiCall: APICall = 'GET /api/users'; // ✅
const invalid: APICall = 'INVALID /api/users'; // ❌ Real-World Examples
Type-Safe Event System
interface EventMap {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'page:view': { path: string; referrer?: string };
}
class TypedEventEmitter {
private listeners: {
[K in keyof EventMap]?: ((data: EventMap[K]) => void)[];
} = {};
on<K extends keyof EventMap>(
event: K,
listener: (data: EventMap[K]) => void
) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
const eventListeners = this.listeners[event];
if (eventListeners) {
eventListeners.forEach(listener => listener(data));
}
}
}
// Usage with full type safety
const emitter = new TypedEventEmitter();
emitter.on('user:login', (data) => {
// data is fully typed as { userId: string; timestamp: Date }
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.emit('user:login', {
userId: '123',
timestamp: new Date()
}); // ✅
emitter.emit('user:login', {
userId: '123'
// ❌ Error: Property 'timestamp' is missing
}); Database Query Builder
interface User {
id: number;
name: string;
email: string;
age: number;
}
class QueryBuilder<T> {
constructor(private table: string) {}
select<K extends keyof T>(...fields: K[]): Pick<T, K>[] {
// Implementation would build SQL SELECT query
throw new Error('Not implemented');
}
where<K extends keyof T>(
field: K,
operator: '=' | '!=' | '>' | '<',
value: T[K]
): this {
// Implementation would add WHERE clause
return this;
}
orderBy<K extends keyof T>(field: K, direction: 'ASC' | 'DESC' = 'ASC'): this {
// Implementation would add ORDER BY clause
return this;
}
}
// Usage
const userQuery = new QueryBuilder<User>('users');
const result = userQuery
.select('id', 'name') // Only id and name are returned
.where('age', '>', 18) // Type-safe field names and values
.orderBy('name', 'ASC'); // Type-safe field names Form Validation
type ValidationRule<T> = (value: T) => string | null;
interface FormField<T> {
value: T;
rules: ValidationRule<T>[];
error?: string;
}
type FormSchema<T> = {
[K in keyof T]: FormField<T[K]>;
};
class TypedForm<T extends Record<string, any>> {
constructor(private schema: FormSchema<T>) {}
validate(): { isValid: boolean; errors: Partial<Record<keyof T, string>> } {
const errors: Partial<Record<keyof T, string>> = {};
let isValid = true;
for (const [fieldName, field] of Object.entries(this.schema)) {
for (const rule of field.rules) {
const error = rule(field.value);
if (error) {
errors[fieldName as keyof T] = error;
isValid = false;
break;
}
}
}
return { isValid, errors };
}
getValue(): T {
const result = {} as T;
for (const [fieldName, field] of Object.entries(this.schema)) {
result[fieldName as keyof T] = field.value;
}
return result;
}
}
// Usage
interface UserForm {
name: string;
email: string;
age: number;
}
const userForm = new TypedForm<UserForm>({
name: {
value: '',
rules: [
(value) => value.length < 2 ? 'Name must be at least 2 characters' : null,
(value) => value.length > 50 ? 'Name must be less than 50 characters' : null
]
},
email: {
value: '',
rules: [
(value) => !value.includes('@') ? 'Invalid email format' : null
]
},
age: {
value: 0,
rules: [
(value) => value < 0 ? 'Age must be positive' : null,
(value) => value > 120 ? 'Age must be realistic' : null
]
}
}); TypeScript Configuration Best Practices
Strict Mode Configuration
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
}
} Path Mapping
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/utils/*": ["src/utils/*"],
"@/types/*": ["src/types/*"]
}
}
} Common Pitfalls and Solutions
Avoiding any
// ❌ Bad - loses all type safety
function processData(data: any) {
return data.someProperty;
}
// ✅ Better - use generics
function processData<T>(data: T): T extends { someProperty: infer P } ? P : never {
return (data as any).someProperty;
}
// ✅ Best - use proper typing
interface DataWithProperty {
someProperty: string;
}
function processData(data: DataWithProperty) {
return data.someProperty; // Fully type-safe
} Type Guards
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
'email' in obj
);
}
// Usage
function handleUnknownData(data: unknown) {
if (isString(data)) {
// TypeScript knows data is string here
console.log(data.toUpperCase());
}
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.name, data.email);
}
} Assertion Functions
function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new Error('Expected number');
}
}
function processValue(value: unknown) {
assertIsNumber(value);
// TypeScript now knows value is number
return value * 2;
} Performance Considerations
Optimizing Type Checking
- Use
interfaceovertypefor object shapes when possible - Avoid deeply nested conditional types
- Use type imports when importing types only
// ✅ Good - type-only import
import type { User } from './types';
// ✅ Good - mixed import
import { type User, createUser } from './user-service'; Build Performance
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"skipLibCheck": true
}
} Testing TypeScript Code
Type Testing
// Test that types are working as expected
type AssertEqual<T, U> = T extends U ? (U extends T ? true : false) : false;
// Test cases
type Test1 = AssertEqual<Pick<User, 'name'>, { name: string }>; // should be true
type Test2 = AssertEqual<string, number>; // should be false
// Compile-time assertions
const _test1: Test1 = true; // ✅
// const _test2: Test2 = true; // ❌ Type error Runtime Testing
import { describe, it, expect } from 'vitest';
describe('TypedEventEmitter', () => {
it('should emit and listen to events with correct types', () => {
const emitter = new TypedEventEmitter();
let receivedData: EventMap['user:login'] | undefined;
emitter.on('user:login', (data) => {
receivedData = data;
});
const loginData = {
userId: '123',
timestamp: new Date()
};
emitter.emit('user:login', loginData);
expect(receivedData).toEqual(loginData);
});
}); Migration Strategies
Gradual Adoption
// Start with basic types
interface User {
name: string;
email: string;
// Add more fields gradually
}
// Migrate JavaScript to TypeScript incrementally
// 1. Rename .js to .ts
// 2. Add basic type annotations
// 3. Enable stricter compiler options
// 4. Add advanced typing Working with Legacy Code
// Create declaration files for existing JavaScript modules
declare module 'legacy-module' {
export function legacyFunction(arg: string): number;
}
// Use module augmentation to add types to existing libraries
declare module 'some-library' {
interface ExistingInterface {
newProperty: string;
}
} Conclusion
TypeScript’s advanced features enable you to build more robust, maintainable applications. The key benefits include:
- Compile-time error detection - Catch bugs before they reach production
- Better IDE support - Intelligent autocomplete and refactoring
- Self-documenting code - Types serve as documentation
- Refactoring confidence - Large-scale changes become safer
Start by mastering the basics, then gradually incorporate advanced features as your projects grow in complexity. The investment in learning TypeScript’s type system pays dividends in code quality, developer productivity, and long-term maintainability.
Remember: TypeScript is a tool to help you write better JavaScript. Use its features to solve real problems, not just for the sake of complex types. The goal is clearer, more maintainable code that prevents bugs and improves the developer experience.