Type-Driven Development: Specifications Over Implementation

James Phoenix
James Phoenix

Summary

Type-Driven Development (TDD-inverted) writes types first as executable specifications, then implements code to satisfy those types. This approach is especially powerful for LLM-assisted development because types act as deterministic constraints, providing immediate compiler feedback and guiding implementation step-by-step. Achieves 70%+ reduction in implementation errors compared to post-hoc typing.

The Problem

Writing types after code leads to any pollution, loose contracts, and incomplete specifications. For LLM-generated code, this is devastating: without type constraints, the LLM explores a massive solution space with many invalid implementations. Type inference in TypeScript becomes a guessing game rather than a guided process.

The Solution

Write types first as executable specifications, then implement code to satisfy them. Types become the specification that guides implementation. The LLM doesn’t have to invent the interface, it just needs to implement one that satisfies the existing type contract. This reduces the solution space from millions of possibilities to thousands, and compiler errors provide immediate, actionable feedback.

The Problem

When you write types after code, you face a critical challenge: types become an afterthought.

Developers (and LLMs) write code first, then add types to satisfy the compiler. This leads to:

1. The any Escape Hatch

// Write code first...
function processData(data) {  // What should the type be?
  return data.map(item => ({
    id: item.id,
    name: item.name,
    count: item.count
  }));
}

// Then add types to make compiler happy
function processData(data: any): any {  // Too loose!
  return data.map((item: any) => ({ // Lost all type safety
    id: item.id,
    name: item.name,
    count: item.count
  }));
}

Cost: No type checking. Bugs slip through.

2. Incomplete Specifications

// Code-first approach
function createUser(name, email, role) {
  // What's the return type? Object? User? Promise?
  // What fields should it have?
  // What happens if email is invalid?
  // Unclear!
}

// Types added to match implementation
function createUser(name: string, email: string, role: string): any {
  // Still unclear what's returned
}

Cost: Interface is ambiguous. LLM can’t predict expected behavior.

3. LLM Exploration Problem

For LLM-generated code, post-hoc typing is catastrophic:

Without types: 10,000,000+ possible implementations
├─ Could return void
├─ Could throw exceptions  
├─ Could modify input in place
├─ Could return different types on different paths
├─ Could call wrong dependencies
└─ ... millions more variations

With vague types (any): 5,000,000+ still valid
├─ Compiler can't reject invalid options
├─ LLM must guess which is "right"
├─ No immediate feedback on mistakes
└─ Result: 50%+ of generated code needs fixing

With precise types: <1,000 valid implementations
├─ Type errors immediately flag invalid approaches
├─ LLM is guided toward correct solutions
├─ Compiler feedback is clear and actionable
└─ Result: 90%+ of generated code works

4. The Documentation Problem

// After-the-fact types don't document intent
function authenticate(
  email: string,
  password: string
): Promise<User | null> {  // Does null mean invalid credentials or error?
                           // Does Promise rejection mean auth failure or system error?
                           // Unclear!
}

// Compare to type-first (next section)
type AuthResult = 
  | { success: true; user: User }
  | { success: false; error: AuthError };

function authenticate(
  email: string,
  password: string
): Promise<AuthResult>;  // Crystal clear!

The Solution

Type-Driven Development inverts the traditional workflow: Write types first, implementation second.

Instead of code ➜ types, use: Types ➜ Implementation

The Core Idea

Types are executable specifications. When you write types first:

  1. You clarify intent before writing any logic
  2. You create a contract that implementation must satisfy
  3. The compiler validates each step of implementation
  4. LLMs receive guidance in the form of type errors
  5. Tests naturally follow from the type contract

Example: User Repository

Step 1: Define Types (Specification)

// First, define what the repository does
type User = {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
};

type UserInsert = Omit<User, 'id' | 'createdAt'>;
type UserUpdate = Partial<UserInsert>;

type UserRepository = {
  findById: (id: string) => Promise<User | null>;
  findByEmail: (email: string) => Promise<User | null>;
  create: (data: UserInsert) => Promise<User>;
  update: (id: string, data: UserUpdate) => Promise<User>;
  delete: (id: string) => Promise<void>;
  list: () => Promise<User[]>;
};

What this specifies:

  • Exactly what data a User has
  • What insertion/update data looks like
  • Every method the repository provides
  • Exact parameter and return types
  • Error handling (null vs exception)

No ambiguity. Crystal clear.

Step 2: Implement to Satisfy Types

// Now implement to satisfy the type contract
export const createUserRepository = (
  deps: { db: Database }
): UserRepository => {
  return {
    findById: async (id: string): Promise<User | null> => {
      // TypeScript enforces:
      // - Parameter must be string
      // - Return must be Promise<User | null> (not void, not User only)
      // - Can't throw without return type change
      // - Must handle null case
      const row = await deps.db.query(
        'SELECT * FROM users WHERE id = $1',
        [id]
      );
      return row ? parseRow(row) : null;
    },

    create: async (data: UserInsert): Promise<User> => {
      // TypeScript enforces:
      // - Parameter matches UserInsert exactly
      // - Must return User (not null, not partial)
      // - Can't return void
      const row = await deps.db.query(
        'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
        [data.email, data.name]
      );
      return parseRow(row);
    },

    update: async (id: string, data: UserUpdate): Promise<User> => {
      // TypeScript enforces:
      // - UserUpdate is optional fields (Partial<UserInsert>)
      // - Still must return full User (not partial)
      const updates = Object.entries(data)
        .map(([key, value], i) => `${key} = $${i + 2}`)
        .join(', ');
      
      const row = await deps.db.query(
        `UPDATE users SET ${updates} WHERE id = $1 RETURNING *`,
        [id, ...Object.values(data)]
      );
      return parseRow(row);
    },

    delete: async (id: string): Promise<void> => {
      // TypeScript enforces:
      // - Must return void (not the deleted record, not boolean)
      await deps.db.query('DELETE FROM users WHERE id = $1', [id]);
      // No return needed
    },

    list: async (): Promise<User[]> => {
      // TypeScript enforces:
      // - Must return array of User (not array of partial, not single User)
      const rows = await deps.db.query('SELECT * FROM users');
      return rows.map(parseRow);
    },
  };
};

What TypeScript ensures:

  • ✅ All 6 methods implemented
  • ✅ Parameter types match specification
  • ✅ Return types satisfy contract
  • ✅ Can’t modify the interface without updating types
  • ✅ Can’t introduce subtle bugs like wrong return types

Why This is Better for LLMs

1. Deterministic Constraints

Types narrow the solution space from millions to thousands:

// Without type specification
function createUser(name, email) {
  // What to return? Any of these is "valid":
  return User;                    // returns instance
  return { id: 1, name, email };  // returns POJO
  return new User(name, email);   // returns constructor result
  return Promise.resolve(user);    // returns Promise
  return async () => user;         // returns function
  // ... 10,000 more variations
}

// With type specification
function createUser(name: string, email: string): Promise<User> {
  // ONLY valid return:
  return Promise.resolve(new User(name, email));
  // Anything else fails type check
}

For LLMs: Each type eliminates millions of invalid implementations.

2. Compiler Feedback is Immediate and Actionable

// LLM starts implementing...
findById: async (id: string): Promise<User | null> => {
  const row = await this.db.query('SELECT...');
  return row;  // ❌ Type Error: 'any' is not assignable to 'User'
  // LLM gets clear message: parse the row into User
}

// LLM refines...
findById: async (id: string): Promise<User | null> => {
  const row = await this.db.query('SELECT...');
  if (!row) return null;  // ✅ Type satisfied: null is valid
  return parseRow(row);   // ✅ Type satisfied: returns User
}

For LLMs: No guessing. Errors guide implementation.

Udemy Bestseller

Learn Prompt Engineering

My O'Reilly book adapted for hands-on learning. Build production-ready prompts with practical exercises.

4.5/5 rating
306,000+ learners
View Course

3. Self-Documenting

Types are the specification:

// From these types alone, anyone (including an LLM) knows:
type UserRepository = {
  findById: (id: string) => Promise<User | null>;
  create: (data: UserInsert) => Promise<User>;
  update: (id: string, data: UserUpdate) => Promise<User>;
  delete: (id: string) => Promise<void>;
};

// What data users have
type User = { id: string; email: string; name: string; createdAt: Date };

// What fields can be inserted/updated
type UserInsert = Omit<User, 'id' | 'createdAt'>;
type UserUpdate = Partial<UserInsert>;

// Error handling: findById returns null, create doesn't
// Deletion returns void (confirmation not data)
// Updates return the full User

No ambiguity. No need for documentation comments. The types are the docs.

4. Incremental Implementation

LLMs can implement one method at a time:

// Step 1: LLM implements findById
findById: async (id: string): Promise<User | null> => {
  const row = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
  return row ? parseRow(row) : null;
}

// Step 2: Only if method 1 passes type checks
// LLM implements create
create: async (data: UserInsert): Promise<User> => {
  const row = await this.db.query(
    'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
    [data.email, data.name]
  );
  return parseRow(row);
}

// Step 3: Continue incrementally
// Each method validated independently

For LLMs: Build confidence through incremental success, not massive implementation then fixing.

Comparison: Type-Driven vs Test-Driven vs Traditional

Traditional Approach (Code → Types → Tests)

// 1. Write code
function authenticate(email, password) {
  // Hope it works
}

// 2. Add types to make compiler happy
function authenticate(email: any, password: any): any {
  // Types don't help much
}

// 3. Write tests to catch mistakes
it('should authenticate valid user', () => {
  // Discovered problems too late
});

Problems:

  • ❌ Types are afterthought
  • ❌ No compile-time guidance
  • ❌ Tests catch errors late
  • ❌ Slow feedback loop
  • ❌ LLM generates wrong code first

Test-Driven Approach (Tests → Code → Types)

// 1. Write tests
it('should return success for valid credentials', () => {
  const result = await authenticate('[email protected]', 'pass');
  expect(result.success).toBe(true);
});

// 2. Write code to pass tests
function authenticate(email, password) {
  return { success: email === '[email protected]' && password === 'pass' };
}

// 3. Add types
function authenticate(
  email: string,
  password: string
): { success: boolean } {
  // ...
}

Benefits:

  • ✅ Tests provide guidance
  • ✅ Red-green-refactor rhythm
  • ✅ Behavior is specified

Problems:

  • ❌ Tests run at runtime (slower feedback)
  • ❌ Types still come last
  • ❌ Interface still not fully specified
  • ❌ LLM doesn’t see full contract upfront

Type-Driven Approach (Types → Code → Tests)

// 1. Write types as specification
type AuthResult = 
  | { success: true; user: User; token: string }
  | { success: false; error: 'invalid-credentials' | 'user-not-found' };

function authenticate(
  email: string,
  password: string
): Promise<AuthResult>;

// 2. Implement to satisfy types
function authenticate(email, password): Promise<AuthResult> {
  // TypeScript guides every step
  // - Must return AuthResult shape
  // - success=true requires user AND token
  // - success=false requires error field
}

// 3. Write tests to validate behavior
it('should return success with user and token', async () => {
  const result = await authenticate('[email protected]', 'pass');
  if (result.success) {
    expect(result.user).toBeDefined();
    expect(result.token).toBeDefined();
  }
});

Benefits:

  • ✅ Types provide compile-time guidance (fastest feedback)
  • ✅ Contract is fully specified upfront
  • ✅ Error cases are explicit (discriminated union)
  • ✅ LLM sees complete interface before implementing
  • ✅ Compiler validates each implementation step
  • ✅ Tests validate behavior on valid specs

Why superior for LLMs:

  • Types eliminate invalid implementations at compile time
  • Tests validate behavior on types that already satisfy structure
  • Combined: 90%+ of generated code works immediately

Implementation Workflow

Phase 1: Define Types

// 1. Define domain types
type Post = {
  id: string;
  title: string;
  content: string;
  authorId: string;
  createdAt: Date;
  updatedAt: Date;
};

type PostInsert = Omit<Post, 'id' | 'createdAt' | 'updatedAt'>;
type PostUpdate = Partial<PostInsert>;

// 2. Define repository interface
type PostRepository = {
  findById: (id: string) => Promise<Post | null>;
  findByAuthor: (authorId: string) => Promise<Post[]>;
  create: (data: PostInsert) => Promise<Post>;
  update: (id: string, data: PostUpdate) => Promise<Post | null>;
  delete: (id: string) => Promise<boolean>;
  search: (query: string) => Promise<Post[]>;
};

// 3. Define error types
type PostError = 
  | { kind: 'not-found'; id: string }
  | { kind: 'validation-failed'; errors: string[] }
  | { kind: 'database-error'; message: string };

type PostResult<T> = 
  | { success: true; data: T }
  | { success: false; error: PostError };

Phase 2: Prompt LLM with Types Only

# Implement PostRepository

You have the following types:

```typescript
type Post = { id: string; title: string; ... };
type PostInsert = Omit<Post, 'id' | 'createdAt' | 'updatedAt'>;
type PostRepository = {
  findById: (id: string) => Promise<Post | null>;
  // ... 5 more methods
};
```markdown

Implement the PostRepository interface using these types. Your implementation must:
1. Satisfy all type constraints
2. Compile without type errors
3. Handle null cases correctly
4. Return correct types for each method

Phase 3: LLM Implements

export const createPostRepository = (deps: { db: Database }): PostRepository => {
  return {
    findById: async (id: string): Promise<Post | null> => {
      const row = await deps.db.query('SELECT * FROM posts WHERE id = $1', [id]);
      return row ? mapToPost(row) : null;
    },
    // ... remaining methods
  };
};

Phase 4: Compile and Validate

# Compile to check types
npm run build

# Output:
# ✅ No compilation errors
# ✅ All methods satisfy types
# ✅ Return types correct
# ✅ Parameter types correct

Phase 5: Run Tests

# Tests were already defined (from type spec)
npm test

# Output:
# ✅ findById returns correct Post
# ✅ findById returns null when not found
# ✅ create inserts and returns new Post
# ✅ update returns null when not found
# ✅ delete returns boolean

Advanced Patterns

Pattern 1: Discriminated Unions for Error Handling

Instead of throwing exceptions, use types to represent outcomes:

// ❌ Bad: Ambiguous error handling
function findPost(id: string): Post {
  // Throws on error? Returns null? Unclear from type
}

// ✅ Good: Type encodes outcome
type FindPostResult = 
  | { ok: true; post: Post }
  | { ok: false; reason: 'not-found' | 'access-denied' | 'deleted' };

function findPost(id: string): Promise<FindPostResult> {
  // Type makes error cases explicit
  // LLM knows to handle both success and all failure modes
}

// Implementation is guided by types:
const result = await findPost('123');
if (result.ok) {
  console.log(result.post);  // ✅ post definitely exists
} else {
  console.log(result.reason); // ✅ reason definitely exists
}

Pattern 2: Builder Types

Use types to guide multi-step processes:

// Type spec guides implementation
type QueryBuilder = {
  where: (field: string, value: unknown) => QueryBuilder;
  orderBy: (field: string, direction: 'asc' | 'desc') => QueryBuilder;
  limit: (count: number) => QueryBuilder;
  execute: () => Promise<Post[]>;
};

// Implementation flows naturally from type
const posts = await createQueryBuilder(db)
  .where('authorId', '123')
  .orderBy('createdAt', 'desc')
  .limit(10)
  .execute();

Pattern 3: Exhaustiveness Checking

Use types to ensure all cases are handled:

// Type spec
type BlogAction = 
  | { type: 'CREATE_POST'; payload: PostInsert }
  | { type: 'UPDATE_POST'; payload: { id: string; data: PostUpdate } }
  | { type: 'DELETE_POST'; payload: { id: string } }
  | { type: 'PUBLISH_POST'; payload: { id: string } };

// Implementation is guided to handle all cases
function handleBlogAction(action: BlogAction): Promise<void> {
  switch (action.type) {
    case 'CREATE_POST':
      // ✅ payload is PostInsert
      return createPost(action.payload);
    case 'UPDATE_POST':
      // ✅ payload is { id, data }
      return updatePost(action.payload.id, action.payload.data);
    case 'DELETE_POST':
      // ✅ payload is { id }
      return deletePost(action.payload.id);
    case 'PUBLISH_POST':
      // ✅ payload is { id }
      return publishPost(action.payload.id);
    // ❌ If you forget a case, TypeScript error
  }
}

Best Practices

1. Write Complete Type Specs

// ❌ Incomplete type spec
type User = {
  id: string;
  email: string;
};

// ✅ Complete type spec
type User = {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
  isActive: boolean;
  createdAt: Date;
  lastLogin: Date | null;
};

Why: LLM can’t infer missing fields. Complete types guide complete implementations.

2. Use Discriminated Unions for Outcomes

// ❌ Ambiguous
function authenticate(): User | null;
// Does null mean wrong credentials? User not found? Blocked?

// ✅ Clear
type AuthResult = 
  | { success: true; user: User; token: string }
  | { success: false; error: 'invalid-credentials' | 'user-not-found' | 'account-blocked' };

function authenticate(): Promise<AuthResult>;
// All outcomes explicit

3. Separate Insert/Update from Read Types

// ❌ Single type is ambiguous
function createUser(data: User): Promise<User>;
// User includes id, createdAt... but those shouldn't be in insert data

// ✅ Separate types are clear
type User = { id: string; email: string; name: string; createdAt: Date };
type UserInsert = Omit<User, 'id' | 'createdAt'>;

function createUser(data: UserInsert): Promise<User>;
// Clear what data is provided vs what's returned

4. Define Immutable Types

// ❌ Mutable types can hide side effects
type Post = {
  title: string;
  content: string;
};

function updatePost(post: Post): void {
  post.title = 'Modified'; // Side effect, not obvious from type
}

// ✅ Immutable types make side effects clear
type Post = {
  readonly title: string;
  readonly content: string;
};

function updatePost(post: Post): Post {
  return { ...post, title: 'Modified' }; // Returns new object
}

5. Use Branded Types for Domain Concepts

// ❌ Any string could be a user ID
function findUser(userId: string): Promise<User>;
// Easy to accidentally pass wrong string

// ✅ Branded types prevent mistakes
type UserId = string & { readonly __brand: 'UserId' };

const createUserId = (id: string): UserId => id as UserId;

function findUser(userId: UserId): Promise<User>;
// Type system ensures you pass actual UserId, not random string

Integration with Other Patterns

Type-Driven + Quality Gates

Types are the first quality gate:

1. Type Checking (compile-time)
   ├─ Eliminates type-unsafe code
   └─ Provides immediate feedback

2. Linting (compile-time)
   ├─ Enforces style and patterns
   └─ Catches inconsistencies

3. Unit Tests (runtime)
   ├─ Validates edge cases
   └─ Ensures behavior correctness

4. Integration Tests (runtime)
   ├─ Tests interactions
   └─ Validates end-to-end flows

5. E2E Tests (runtime)
   ├─ Tests user flows
   └─ Ensures production readiness

Types filter out 90% of invalid implementations before any test runs.

Type-Driven + Entropy Reduction

Types reduce entropy (uncertainty) in LLM outputs:

Without type spec: 10,000,000 possible implementations (high entropy)
With type spec: <1,000 possible implementations (low entropy)
With tests: <100 valid implementations

Result: LLM output is predictable and correct

Type-Driven + Test-Based Regression

When tests catch bugs, add discriminated union cases:

// Bug: Function returned undefined sometimes
type Result = 
  | { ok: true; data: Post }
  | { ok: false; error: PostError };

// Test caught it, type prevents regression
function getPost(): Promise<Result> {
  // Can't return undefined anymore
  // Type forces explicit success/failure
}

Common Mistakes

❌ Mistake 1: Over-Using any

// Types first, but then...
function process(data: any): any {  // Defeats purpose!
  // No type guidance, no constraints
}

Fix: Use specific types even if they require imports.

❌ Mistake 2: Types Without Tests

// Types spec method signature
function createUser(data: UserInsert): Promise<User> {
  // Types enforce structure, but not behavior
  // Could still have bugs in logic
}

Fix: Combine types with tests for complete safety.

❌ Mistake 3: Too Permissive Union Types

// ❌ Doesn't help much
type Result = Promise<User | null | undefined | Error>;
// Still multiple outcomes, unclear which

// ✅ Explicit discriminated union
type Result = 
  | { success: true; user: User }
  | { success: false; reason: 'not-found' | 'error' };

❌ Mistake 4: Forgetting to Document Type Decisions

// ❌ Why does this return null vs error?
function findPost(id: string): Promise<Post | null>;

// ✅ Document the decision
/**
 * Find a post by ID.
 * 
 * @returns null if post not found (common case)
 * @throws Error only on database failures (rare, exceptional)
 */
function findPost(id: string): Promise<Post | null>;

Measuring Success

Metric 1: Type Coverage

# What % of code has explicit types?
Target: >95%

Metric 2: LLM Error Rate

Without type spec: 40-60% of generated code has errors
With type spec: 5-15% of generated code has errors
With type spec + tests: <5% errors

Metric 3: Compilation Success

# First-time compilation
Target: >90% of LLM-generated code compiles without type errors

Metric 4: Tests Passing

# First-time test pass rate
Target: >85% of LLM-generated code passes all tests

Real-World Example: E-Commerce Order Service

Step 1: Define Types

type Money = { amount: number; currency: 'USD' | 'EUR' };
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';

type OrderItem = {
  productId: string;
  quantity: number;
  price: Money;
};

type Order = {
  id: string;
  customerId: string;
  items: OrderItem[];
  total: Money;
  status: OrderStatus;
  createdAt: Date;
  shippedAt: Date | null;
  deliveredAt: Date | null;
};

type OrderInsert = Omit<Order, 'id' | 'createdAt' | 'shippedAt' | 'deliveredAt'>;
type OrderUpdate = {
  status?: OrderStatus;
  shippedAt?: Date;
  deliveredAt?: Date;
};

type OrderRepository = {
  findById: (id: string) => Promise<Order | null>;
  findByCustomer: (customerId: string) => Promise<Order[]>;
  create: (data: OrderInsert) => Promise<Order>;
  update: (id: string, data: OrderUpdate) => Promise<Order | null>;
  cancel: (id: string) => Promise<boolean>;
};

Step 2: Prompt LLM

# Implement OrderRepository

You have the types defined above. Implement the OrderRepository interface.

Requirements:
1. All methods must satisfy their type signatures
2. Queries use SQL (database.ts provides query method)
3. Error cases return null, don't throw
4. Dates are automatically set (createdAt on create, shippedAt/deliveredAt on update)

Step 3: LLM Generates Code

export const createOrderRepository = (
  deps: { db: Database }
): OrderRepository => {
  return {
    findById: async (id: string): Promise<Order | null> => {
      const row = await deps.db.query(
        'SELECT * FROM orders WHERE id = $1',
        [id]
      );
      return row ? mapRowToOrder(row) : null;
    },

    create: async (data: OrderInsert): Promise<Order> => {
      const row = await deps.db.query(
        `INSERT INTO orders (customer_id, total_amount, total_currency, status)
         VALUES ($1, $2, $3, $4)
         RETURNING *`,
        [data.customerId, data.total.amount, data.total.currency, data.status]
      );
      return mapRowToOrder(row);
    },

    // ... remaining methods
  };
};

Step 4: Compile

$ npm run build
✅ src/repositories/order.ts - No errors
✅ All types satisfied
✅ All methods implemented

Step 5: Test

$ npm test
✅ findById returns order when found
✅ findById returns null when not found
✅ create inserts and returns order with createdAt
✅ update changes status and returns updated order
✅ cancel returns true/false correctly

Conclusion

Type-Driven Development flips the traditional workflow:

Traditional: Code → Types → Tests → Bugs

Type-Driven: Types → Code → Tests → Done

Key Benefits for LLM-Assisted Development:

  1. Specifications are explicit: Types encode requirements, no ambiguity
  2. Compiler provides guidance: Type errors guide implementation
  3. Solution space is narrow: Millions of invalid implementations eliminated
  4. Quality is built-in: Types enforce structure, tests validate behavior
  5. Feedback is immediate: Compile errors appear before tests run

Results:

  • 90%+ of LLM-generated code compiles without errors
  • 85%+ of LLM-generated code passes tests first try
  • 70%+ fewer revisions needed
  • Implementation is guided, not guessed

Related Concepts

References

Topics
ConstraintsIncremental DevelopmentLlm OptimizationQuality GatesSpecificationsTddType Driven DevelopmentTypesTypescript

More Insights

Cover Image for Thought Leaders

Thought Leaders

People to follow for compound engineering, context engineering, and AI agent development.

James Phoenix
James Phoenix
Cover Image for Systems Thinking & Observability

Systems Thinking & Observability

Software should be treated as a measurable dynamical system, not as a collection of features.

James Phoenix
James Phoenix