Skip to content

Latest commit

 

History

History
272 lines (205 loc) · 6.3 KB

TYPESCRIPT_STANDARDS.md

File metadata and controls

272 lines (205 loc) · 6.3 KB

TypeScript Standards & Manifesto

Our philosophy: If it's hard to test, it's hard to use. Write code that's obvious to humans and AI.


The Core Rules

1. Extract Pure Functions First

Classes hide complexity. Pure functions expose it.

❌ BAD:

class StatsManager {
  private mergeStats(current, incremental) { /* 100 lines */ }
}
// Can't test without instantiating class

✅ GOOD:

// utils/stats-merger.ts
export function mergeStats(current: Stats, incremental: Stats): Stats {
  return { files: current.files + incremental.files, ... };
}
// Direct test: expect(mergeStats(a, b)).toEqual(expected)

When? If it's >20 lines, pure (no side effects), or reusable → extract it.


2. No Type Assertions Without Validation

TypeScript types vanish at runtime. Validate external data.

❌ BAD (found in old codebase):

const request = message.payload as unknown as ExplorationRequest;
// Runtime bomb waiting to happen

✅ GOOD (Real example from MCP adapters):

import { z } from 'zod';
import { validateArgs } from './validation.js';

// Define schema once
const ExploreArgsSchema = z.object({
  action: z.enum(['pattern', 'similar', 'relationships']),
  query: z.string().min(1),
  limit: z.number().int().min(1).max(100).default(10),
  threshold: z.number().min(0).max(1).default(0.7),
}).strict(); // Reject unknown properties

// Use in adapter
async execute(args: Record<string, unknown>): Promise<ToolResult> {
  const validation = validateArgs(ExploreArgsSchema, args);
  if (!validation.success) {
    return validation.error; // Detailed error with field paths
  }

  // TypeScript knows exact types! Zero assertions needed.
  const { action, query, limit, threshold } = validation.data;
  //      ^'pattern'|'similar'|'relationships'  ^string  ^number  ^number
}

Benefits:

  • ✅ Zero type assertions (as, !)
  • ✅ Automatic TypeScript type inference
  • ✅ Runtime validation with detailed errors
  • ✅ Schemas are testable (see packages/mcp-server/src/schemas/__tests__)
  • ✅ ~63% less validation code vs. manual checks

Rule: Never use as, as unknown as, or ! without runtime checks.


3. Result Types, Not Exceptions

Exceptions are invisible in type signatures. Result types are explicit.

❌ BAD:

async function fetchUser(id: string): Promise<User> {
  if (!valid) throw new Error('Invalid');
  if (!found) throw new Error('Not found');
  return user;
}
// Forces try-catch, unclear what can fail

✅ GOOD:

type Result<T, E = AppError> = 
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: string): Promise<Result<User>> {
  if (!valid) return { ok: false, error: { code: 'INVALID_ID' } };
  if (!found) return { ok: false, error: { code: 'NOT_FOUND' } };
  return { ok: true, value: user };
}

// Clean usage
const result = await fetchUser('123');
if (!result.ok) {
  logger.error(result.error);
  return;
}
const user = result.value; // Type-safe

When to throw: Only for programmer errors (throw new Error('INVARIANT: ...'))


4. Inject Dependencies

Hard-coded dependencies = untestable code.

❌ BAD:

class PlannerAgent {
  async createPlan() {
    const github = new GitHubClient(); // Can't mock
  }
}

✅ GOOD:

interface PlannerDeps {
  github: GitHubClient;
  indexer: RepositoryIndexer;
}

class PlannerAgent {
  constructor(private deps: PlannerDeps) {}
}

// Testing is easy
new PlannerAgent({ github: mockGitHub, indexer: mockIndexer });

Size Limits

  • Modules: < 300 lines → Split by domain
  • Classes: < 400 lines → Use Strategy pattern
  • Functions: < 50 lines → Extract helpers

Current refactoring targets:

  • explore-adapter.ts (690 lines)
  • github-adapter.ts (724 lines)
  • coordinator.ts (480 lines)

Organization

src/
├── utils/              # Pure functions
│   ├── validation.ts   (120 lines, 100% coverage)
│   ├── validation.test.ts
│   ├── formatting.ts   (150 lines, 100% coverage)
│   ├── formatting.test.ts
│   └── index.ts        (barrel export)
└── index.ts            (integration)

Rules:

  • Organize by domain (not "misc")
  • Colocate tests with source
  • Barrel exports (index.ts)
  • Each module < 300 lines

Testing Requirements

Type Coverage Why
Pure utilities 100% Easy to test, no excuses
Integration 80%+ Side effects, mocks needed
CLI/UI 60%+ Harder to test

Test factories:

// __tests__/factories.ts
export function createMessage(overrides?: Partial<Message>): Message {
  return { id: randomUUID(), type: 'request', ...overrides };
}

// Use in tests
const msg = createMessage({ type: 'response' });

Error Handling

Standard error format:

interface AppError {
  code: string;           // 'NOT_FOUND', 'VALIDATION_ERROR'
  message: string;        // Human-readable
  details?: unknown;      // Context
  recoverable: boolean;   // Can retry?
  suggestion?: string;    // What to do
}

Commit Checklist

Before committing:

  • No as, as unknown as, or !
  • External data validated (Zod)
  • Result types for expected failures
  • Dependencies injected
  • Pure functions extracted
  • 100% coverage on utilities
  • Modules < 300 lines, classes < 400 lines

Real Example: Recent Refactoring

Before: 102-line mergeIncrementalStats() method with mutations After: 6 pure functions in stats-merger.ts (225 lines, 17 tests, 100% coverage)

Impact:

  • Tests run in <1ms (no setup)
  • No side effects to track
  • Reusable across packages
  • AI can understand each function

See: docs/REFACTORING_SUMMARY.md


Tools

# Runtime validation
pnpm add zod

# Result type
pnpm add neverthrow  # or implement your own

# Property-based testing
pnpm add -D fast-check

Resources


Questions? If you're unsure whether to extract something: extract it.