You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
CUPID is a framework for creating "joyful code" that humans can understand and
enjoy working with. As Martin Fowler noted: "Good programmers write code that
humans can understand."
The Five Properties
🧩 Composable
Code that "plays well with others"
Small Surface Area: Narrow, opinionated APIs with less to learn and less
chance of conflict
Intention-Revealing: Easy to discover and assess whether it meets your
needs
Minimal Dependencies: Reduces compatibility issues and simplifies
management
🔧 Unix Philosophy
Code that "does one thing well"
Components work together through composition (like Unix pipes)
Focus on specific, well-defined purposes
Build complex systems from simple, focused elements
Outside-in perspective about purpose (vs. SRP's inside-out organization)
🎯 Predictable
Code that "does what you expect"
Behaves as Expected: Obvious behavior from structure and naming
Deterministic: Consistent results with robustness, reliability, and
resilience
Observable: Proper instrumentation, telemetry, monitoring, and alerting
🎨 Idiomatic
Code that "feels natural"
Written with empathy for other developers
Conforms to language's natural style and conventions
Establishes consistent local idioms when language lacks guidelines
Reduces cognitive load through familiarity
🏗️ Domain-based
Models the problem domain
Domain-based Language: Types and naming reflect the problem domain
Domain-based Boundaries: Module boundaries align with domain boundaries
Core Philosophy
The framework aims to:
Reduce cognitive distance between problems and solutions
Make software comfortable to navigate and modify
Enable developers to make changes confidently
Create a sense of joy while programming
Key Insight
Good composable code often emerges from domain fluency, where developers
naturally choose similar, intuitive naming and design approaches. The goal is
moving code toward the "center" of these properties to incrementally improve
software quality and developer experience.
"First, your refactoring was not part of our negotiations nor our agreement"
-- Barbossa, Project Lead, 2003.
This post outlines principles for writing TypeScript that prioritizes developer
experience and maintainability. These principles focus on reducing complexity
while maintaining expressiveness and type safety.
Prerequisites
Before diving into the principles, let's establish three foundational concepts
that underpin effective TypeScript development:
Code Volume
The amount of code that developers need to manage directly impacts their ability
to understand, maintain, and extend a system. We should:
Minimize the amount of code that developers must manage
Avoid unnecessary abstractions that add complexity without clear benefits
Keep service layers "shallow" - avoid deep call stacks that obscure business
logic
Focus on a functional core with an imperative shell pattern
Control Flow
The logical flow through your application should be predictable and easy to
follow:
Manage logical branches carefully to avoid complex decision trees
Minimize nested conditionals and complex branching logic
Use clear, predictable logic paths that are easy to trace
Avoid callback hell and deeply nested async operations
State Management
How data changes over time in your application affects its predictability:
Maintain clear tracking of data changes throughout the application
Prevent unpredictable behavior by controlling state mutations
Minimize state modifications and make them explicit
Ensure that state changes are readable and maintainable
Principles
1. Composition Over Inheritance
Favor composition over inheritance to create more flexible and maintainable code
structures.
Why this matters:
Inheritance creates tight coupling between classes
Deep inheritance hierarchies become difficult to understand and modify
Composition allows for better code reuse and flexibility
How to apply:
Prefer combining simple objects rather than extending complex classes
Use interfaces to enforce behaviors rather than abstract base classes
Avoid deep inheritance hierarchies (more than 2-3 levels)
Use dependency injection for managing complex property relationships
Transform untyped data into well-typed data at system boundaries rather than
just validating it.
Note
Boundary refers to both entry points and exit points for the system. It's
at the "edge" of how data enters the system (think of things like from
requests to your server and responses from your server's requests).
Why this matters:
Validation alone doesn't guarantee type safety throughout your application
Parsing creates strongly typed data that TypeScript can reason about
Early transformation reduces the need for defensive programming throughout
your codebase
How to apply:
Transform untyped data to well-typed data at system boundaries (API endpoints,
file I/O, etc.)
Validate and transform data as early as possible in your application flow
Use tools like Zod, io-ts, or similar libraries for type parsing
Keep validation and parsing logic centralized and reusable
Example:
import{z}from"zod";// Define your schemaconstUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),age: z.number().min(0),});typeUser=z.infer<typeofUserSchema>;// Parse at the boundaryfunctioncreateUser(rawData: unknown): User{returnUserSchema.parse(rawData);// Throws if invalid}// Or use safe parsingfunctioncreateUserSafe(rawData: unknown,): {success: true;data: User}|{success: false;error: string}{constresult=UserSchema.safeParse(rawData);if(result.success){return{success: true,data: result.data};}return{success: false,error: result.error.message};}
3. Never Throw Errors
Distinguish between "expected" and "unexpected" errors, and handle them
appropriately.
Why this matters:
Thrown exceptions can interrupt program flow unexpectedly
They make error handling implicit and easy to forget
Result types make error handling explicit and part of the type system
How to apply:
Distinguish between "expected" errors (validation failures, business logic
violations) and "unexpected" errors (system failures, programming bugs)
Return result types instead of throwing exceptions for expected errors
Use libraries like "neverthrow" or similar for structured error handling
Handle errors at appropriate system boundaries
Example:
// Instead of throwingfunctiondivide(a: number,b: number): number{if(b===0){thrownewError("Division by zero");}returna/b;}// Return a result typetypeResult<T,E>={success: true;data: T}|{success: false;error: E};functiondivide(a: number,b: number): Result<number,string>{if(b===0){return{success: false,error: "Division by zero"};}return{success: true,data: a/b};}// Usageconstresult=divide(10,2);if(result.success){console.log(result.data);// TypeScript knows this is a number}else{console.error(result.error);// TypeScript knows this is a string}
4. Metadata
Use clear conventions to distinguish between internal and external properties.
Why this matters:
Clear naming conventions help developers understand what's safe to use
Metadata helps with serialization and API communication
It enables better tooling and code generation
How to apply:
Use clear conventions for internal vs. external properties
Prefix internal properties with conventions like _tag, _type, or
__internal
Enable clear communication about data structure across system boundaries
Use metadata for discriminated unions and type narrowing
Example:
// Use metadata for discriminated unionstypeShape=|{_tag: "circle";radius: number}|{_tag: "rectangle";width: number;height: number}|{_tag: "triangle";base: number;height: number};functioncalculateArea(shape: Shape): number{switch(shape._tag){case"circle":
returnMath.PI*shape.radius**2;case"rectangle":
returnshape.width*shape.height;case"triangle":
return(shape.base*shape.height)/2;}}// Internal vs external propertiesinterfaceUser{id: string;name: string;email: string;_internal: {hashedPassword: string;sessionToken: string;};}
5. Define Source of Truth
Establish clear ownership of data definitions and generate derived types
programmatically.
Why this matters:
Multiple sources of truth lead to inconsistencies
Manual synchronization between types is error-prone
Generated types stay in sync automatically
How to apply:
Use schema validation libraries as your source of truth for data shapes
Separate concerns between different data representations (API, database, UI)
Generate TypeScript type definitions programmatically from schemas
Use tools that can generate types from OpenAPI specs, database schemas, or
GraphQL schemas
Example:
// Define schema as source of truthconstApiUserSchema=z.object({id: z.string(),name: z.string(),email: z.string().email(),createdAt: z.string().datetime(),});// Generate types from schematypeApiUser=z.infer<typeofApiUserSchema>;// Transform for different contextstypeDbUser=ApiUser&{hashedPassword: string;updatedAt: Date;};typeUiUser=Pick<ApiUser,"id"|"name"|"email">&{displayName: string;};
6. Let Controllers Tell You Everything
Make all possible responses visible and explicit in your API controllers.
Why this matters:
Hidden error paths make debugging difficult
Explicit response types improve API documentation
Clear error handling reduces surprises for API consumers
How to apply:
Control all request/response flows explicitly
Make all possible response types visible in function signatures
Use minimal catch-all error handling
Document error cases as part of your API contract
Example:
// Make all possible responses explicittypeApiResponse<T>=|{status: "success";data: T}|{status: "validation_error";errors: string[]}|{status: "not_found";message: string}|{status: "server_error";message: string};asyncfunctiongetUser(id: string): Promise<ApiResponse<User>>{// Validate inputif(!id||typeofid!=="string"){return{status: "validation_error",errors: ["User ID is required and must be a string"],};}try{constuser=awaituserRepository.findById(id);if(!user){return{status: "not_found",message: `User with ID ${id} not found`,};}return{status: "success",data: user};}catch(error){return{status: "server_error",message: "An unexpected error occurred",};}}
7. Generate Code
Automate repetitive code generation to reduce boilerplate and ensure
consistency.
Why this matters:
Manual boilerplate is error-prone and time-consuming
Generated code stays consistent with schema changes
Automation reduces the cognitive load on developers
How to apply:
Generate TypeScript types from schemas, APIs, or databases
Use code generation for repetitive patterns like CRUD operations
Automate the creation of API clients from OpenAPI specifications
Generate form validation from your data schemas
8. Avoid Over-Abstraction
Keep abstractions minimal and only introduce them when they provide clear value.
Why this matters:
Over-abstraction can make code harder to understand
Premature abstraction often leads to inflexible designs
Simple, explicit code is often more maintainable
How to apply:
Start with concrete implementations and abstract only when patterns emerge
Prefer explicit code over clever abstractions
Consider the Rule of Three: refactor into abstractions after the third
repetition
Always ask: "Does this abstraction make the code simpler or more complex?"
Additional Recommendations
Use AI Cautiously: While AI tools can be helpful, always review generated
code for correctness and maintainability
Write Code to Enable Easy Refactoring: Structure your code so that changes
can be made safely with good IDE support
Prioritize Developer Experience: Code is read more often than it's
written - optimize for readability and understanding
Conclusion
These principles aim to create more maintainable, readable, and predictable
TypeScript codebases. They emphasize clarity over cleverness, composition over
inheritance, and explicit error handling over hidden exceptions.
Remember: the best code is code that future developers (including yourself) can
easily understand, modify, and extend. These principles help achieve that goal
while leveraging TypeScript's powerful type system to catch errors early and
improve developer productivity.