TypeScript has transformed how I build applications. After using it extensively at Nutrien, Verizon, and Servant, I've collected patterns that make large codebases more maintainable and catch bugs at compile time.
Discriminated Unions for State Management
One of the most powerful TypeScript features is discriminated unions, perfect for modeling application state:
// types/api-state.ts
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// Using the state
function renderReferrals(state: ApiState<Referral[]>) {
switch (state.status) {
case 'idle':
return <div>Click to load referrals</div>;
case 'loading':
return <LoadingSpinner />;
case 'success':
// TypeScript knows state.data exists here
return <ReferralList referrals={state.data} />;
case 'error':
// TypeScript knows state.error exists here
return <ErrorMessage message={state.error} />;
}
}This pattern eliminates entire categories of bugs where you might try to access data when it doesn't exist.
Generic Repository Pattern
For data access, I use a generic repository pattern that works with any entity:
// repositories/base.repository.ts
export abstract class BaseRepository<T> {
constructor(protected db: Database) {}
abstract tableName: string;
async findById(id: string): Promise<T | null> {
const result = await this.db
.select()
.from(this.tableName)
.where('id', id)
.first();
return result || null;
}
async findAll(filters?: Partial<T>): Promise<T[]> {
let query = this.db.select().from(this.tableName);
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
query = query.where(key, value);
});
}
return query;
}
async create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T> {
const [result] = await this.db
.insert(this.tableName)
.values({
...data,
id: uuidv4(),
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return result;
}
async update(id: string, data: Partial<T>): Promise<T> {
const [result] = await this.db
.update(this.tableName)
.set({
...data,
updatedAt: new Date(),
})
.where('id', id)
.returning();
return result;
}
async delete(id: string): Promise<void> {
await this.db.delete(this.tableName).where('id', id);
}
}
// Concrete implementation
export class ReferralsRepository extends BaseRepository<Referral> {
tableName = 'referrals';
async findByPriority(priority: Priority): Promise<Referral[]> {
return this.db
.select()
.from(this.tableName)
.where('priority', priority)
.orderBy('createdAt', 'desc');
}
async findByUserId(userId: string): Promise<Referral[]> {
return this.db
.select()
.from(this.tableName)
.where('userId', userId)
.orderBy('createdAt', 'desc');
}
}Type-Safe Event Emitters
Event-driven architecture benefits enormously from type safety:
// events/typed-emitter.ts
import { EventEmitter } from 'events';
interface Events {
'referral:created': (referral: Referral) => void;
'referral:updated': (referral: Referral, changes: Partial<Referral>) => void;
'referral:deleted': (referralId: string) => void;
'user:login': (user: User) => void;
'user:logout': (userId: string) => void;
}
export class TypedEventEmitter {
private emitter = new EventEmitter();
on<K extends keyof Events>(event: K, listener: Events[K]): void {
this.emitter.on(event, listener);
}
once<K extends keyof Events>(event: K, listener: Events[K]): void {
this.emitter.once(event, listener);
}
emit<K extends keyof Events>(
event: K,
...args: Parameters<Events[K]>
): void {
this.emitter.emit(event, ...args);
}
off<K extends keyof Events>(event: K, listener: Events[K]): void {
this.emitter.off(event, listener);
}
}
// Usage
const events = new TypedEventEmitter();
// TypeScript enforces correct event names and parameter types
events.on('referral:created', (referral) => {
console.log(`New referral: ${referral.motherName}`);
});
// This would be a compile error:
// events.on('invalid:event', () => {}); // Error!
// events.emit('referral:created', 'wrong type'); // Error!Builder Pattern for Complex Objects
For complex object construction, the builder pattern with TypeScript is elegant:
// builders/query.builder.ts
type OrderDirection = 'asc' | 'desc';
interface QueryOptions<T> {
where?: Partial<T>;
orderBy?: Array<[keyof T, OrderDirection]>;
limit?: number;
offset?: number;
include?: string[];
}
export class QueryBuilder<T> {
private options: QueryOptions<T> = {};
where(conditions: Partial<T>): this {
this.options.where = { ...this.options.where, ...conditions };
return this;
}
orderBy(field: keyof T, direction: OrderDirection = 'asc'): this {
if (!this.options.orderBy) {
this.options.orderBy = [];
}
this.options.orderBy.push([field, direction]);
return this;
}
limit(limit: number): this {
this.options.limit = limit;
return this;
}
offset(offset: number): this {
this.options.offset = offset;
return this;
}
include(relations: string[]): this {
this.options.include = relations;
return this;
}
build(): QueryOptions<T> {
return this.options;
}
}
// Usage
const query = new QueryBuilder<Referral>()
.where({ status: 'pending' })
.where({ priority: 'HIGH' })
.orderBy('createdAt', 'desc')
.limit(10)
.include(['user'])
.build();Branded Types for Domain Validation
Branded types prevent mixing up similar primitive types:
// types/branded.ts
type Brand<K, T> = K & { __brand: T };
export type UserId = Brand<string, 'UserId'>;
export type ReferralId = Brand<string, 'ReferralId'>;
export type Email = Brand<string, 'Email'>;
export type PhoneNumber = Brand<string, 'PhoneNumber'>;
// Validation functions that return branded types
export function createUserId(id: string): UserId {
if (!id || id.length === 0) {
throw new Error('Invalid user ID');
}
return id as UserId;
}
export function createEmail(email: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error('Invalid email format');
}
return email as Email;
}
export function createPhoneNumber(phone: string): PhoneNumber {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length !== 10 && cleaned.length !== 11) {
throw new Error('Invalid phone number');
}
return phone as PhoneNumber;
}
// Usage
function sendEmail(email: Email, subject: string, body: string) {
// Implementation
}
// This works
const validEmail = createEmail('user@example.com');
sendEmail(validEmail, 'Hello', 'World');
// This won't compile
// sendEmail('user@example.com', 'Hello', 'World'); // Error!
// You must validate first
const email = createEmail('user@example.com');
sendEmail(email, 'Hello', 'World'); // OKConditional Types for API Responses
Conditional types help model different API response shapes:
// types/api-response.ts
type SuccessResponse<T> = {
success: true;
data: T;
};
type ErrorResponse = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// Type guard
function isSuccess<T>(response: ApiResponse<T>): response is SuccessResponse<T> {
return response.success === true;
}
// Usage
async function fetchReferrals(): Promise<ApiResponse<Referral[]>> {
try {
const response = await fetch('/api/referrals');
const data = await response.json();
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
error: {
code: 'FETCH_ERROR',
message: error.message,
},
};
}
}
// Type-safe handling
const response = await fetchReferrals();
if (isSuccess(response)) {
// TypeScript knows response.data exists
console.log(response.data.length);
} else {
// TypeScript knows response.error exists
console.error(response.error.message);
}Mapped Types for Form State
Managing form state with mapped types:
// types/form-state.ts
type FormField<T> = {
value: T;
error?: string;
touched: boolean;
dirty: boolean;
};
type FormState<T> = {
[K in keyof T]: FormField<T[K]>;
};
type FormErrors<T> = {
[K in keyof T]?: string;
};
// Example entity
interface CreateReferralForm {
motherName: string;
priority: Priority;
notes: string;
userId: string;
}
// Automatically creates form state type
type ReferralFormState = FormState<CreateReferralForm>;
// Helper functions
function createFormState<T>(initialValues: T): FormState<T> {
return Object.entries(initialValues).reduce((acc, [key, value]) => {
acc[key as keyof T] = {
value,
touched: false,
dirty: false,
};
return acc;
}, {} as FormState<T>);
}
function setFieldValue<T, K extends keyof T>(
formState: FormState<T>,
field: K,
value: T[K]
): FormState<T> {
return {
...formState,
[field]: {
...formState[field],
value,
dirty: true,
},
};
}
function setFieldError<T, K extends keyof T>(
formState: FormState<T>,
field: K,
error: string
): FormState<T> {
return {
...formState,
[field]: {
...formState[field],
error,
},
};
}
// Usage
const formState = createFormState<CreateReferralForm>({
motherName: '',
priority: 'MEDIUM',
notes: '',
userId: '',
});
const updated = setFieldValue(formState, 'motherName', 'Jane Doe');Utility Types for DTOs
Creating DTOs with utility types:
// types/dto.ts
// Pick only specific fields
type UserPublicDTO = Pick<User, 'id' | 'email' | 'firstName' | 'lastName' | 'role'>;
// Omit sensitive fields
type UserSafeDTO = Omit<User, 'passwordHash' | 'resetToken'>;
// Make all fields optional for updates
type UpdateUserDTO = Partial<User>;
// Make specific fields required
type CreateUserDTO = Required<Pick<User, 'email' | 'firstName' | 'lastName' | 'role'>> & {
password: string;
};
// Readonly for responses
type UserResponse = Readonly<UserPublicDTO>;
// Deep partial for nested updates
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
type UpdateReferralDTO = DeepPartial<Referral>;Conclusion
These TypeScript patterns have saved me countless hours of debugging. The key is leveraging TypeScript's type system to catch errors at compile time rather than runtime.
Advanced TypeScript takes time to master, but the investment pays off in more maintainable, less buggy code. Start with discriminated unions and branded types, then gradually adopt the more advanced patterns as your needs grow.
The goal isn't to use every advanced TypeScript feature - it's to use the right features to make your code safer and more expressive.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.