Skip to content

Instantly share code, notes, and snippets.

@kevinmichaelchen
Created September 19, 2025 13:55
Show Gist options
  • Select an option

  • Save kevinmichaelchen/e55a938af74ea1bf92373a9f47191223 to your computer and use it in GitHub Desktop.

Select an option

Save kevinmichaelchen/e55a938af74ea1bf92373a9f47191223 to your computer and use it in GitHub Desktop.
Development principles and guidelines

CUPID: Properties for Joyful Code

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 Structure: Code layout mirrors domain organization
  • 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.

Effective TypeScript Principles in 2025

Source: Dennis O'Keeffe's Blog

"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

Example:

// Instead of inheritance
class Animal {
  move() {
    /* implementation */
  }
}

class Dog extends Animal {
  bark() {
    /* implementation */
  }
}

// Prefer composition
interface Movable {
  move(): void;
}

interface Barkable {
  bark(): void;
}

class Dog implements Movable, Barkable {
  move() {
    /* implementation */
  }
  bark() {
    /* implementation */
  }
}

2. Parse, Don't Validate

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 schema
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(0),
});

type User = z.infer<typeof UserSchema>;

// Parse at the boundary
function createUser(rawData: unknown): User {
  return UserSchema.parse(rawData); // Throws if invalid
}

// Or use safe parsing
function createUserSafe(
  rawData: unknown,
): { success: true; data: User } | { success: false; error: string } {
  const result = 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 throwing
function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error("Division by zero");
  }
  return a / b;
}

// Return a result type
type Result<T, E> = { success: true; data: T } | { success: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: "Division by zero" };
  }
  return { success: true, data: a / b };
}

// Usage
const result = 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 unions
type Shape =
  | { _tag: "circle"; radius: number }
  | { _tag: "rectangle"; width: number; height: number }
  | { _tag: "triangle"; base: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape._tag) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
  }
}

// Internal vs external properties
interface User {
  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 truth
const ApiUserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

// Generate types from schema
type ApiUser = z.infer<typeof ApiUserSchema>;

// Transform for different contexts
type DbUser = ApiUser & {
  hashedPassword: string;
  updatedAt: Date;
};

type UiUser = 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 explicit
type ApiResponse<T> =
  | { status: "success"; data: T }
  | { status: "validation_error"; errors: string[] }
  | { status: "not_found"; message: string }
  | { status: "server_error"; message: string };

async function getUser(id: string): Promise<ApiResponse<User>> {
  // Validate input
  if (!id || typeof id !== "string") {
    return {
      status: "validation_error",
      errors: ["User ID is required and must be a string"],
    };
  }

  try {
    const user = await userRepository.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment