TypeScript/React Conventions: Testkitchen Projects

Patterns and conventions from Eliza (1,523 LOC) and GTD (970 LOC). This note describes what those projects do; where it differs from CGD decisions, the decisions and Rules win.

Project Structure

Modular Organization (Eliza)

src/
├── engine/         # Core business logic
├── middleware/     # Express.js middleware
├── rules/          # Domain-specific rules
├── personas/       # Configuration variants
├── cli/            # Command-line interface
├── persistence/    # Data storage layer
└── index.ts        # Public API exports

React Application (GTD)

src/
├── components/     # React components (PascalCase.tsx)
├── context/        # React context providers
├── hooks/          # Custom hooks
├── types/          # TypeScript interfaces
├── persistence/    # Storage utilities
├── __tests__/      # Test files
└── App.tsx         # Root component

Naming Conventions

TypeConventionExamples
InterfacesPascalCaseInboxItem, Rule, Engine
TypesPascalCaseInboxAction, Category, WordType
FunctionscamelCasecreateEngine, isProcessed, getPersona
ConstantsUPPER_SNAKE_CASEMAX_LENGTH, STORAGE_KEY
Files (modules)kebab-caseinbox-context.ts, story-storage.ts
Files (components)PascalCaseCaptureInput.tsx, InboxList.tsx

Type Patterns

Discriminated Unions

export type InboxAction =
  | { type: 'LOAD'; items: InboxItem[] }
  | { type: 'ADD'; content: string }
  | { type: 'CATEGORIZE'; id: string; category: Category }
  | { type: 'SNOOZE'; id: string }
  | { type: 'DELETE'; id: string };
 
// Exhaustive handling in reducer
function inboxReducer(state: InboxState, action: InboxAction): InboxState {
  switch (action.type) {
    case 'LOAD':
      return { ...state, items: action.items };
    case 'ADD':
      return { ...state, items: [...state.items, createItem(action.content)] };
    // TypeScript ensures all cases are handled
  }
}

String-Literal State Types (State Machines)

type ConversationState = 'greeting' | 'main' | 'goodbye';
 
interface Engine {
  getState(): ConversationState;
  process(input: string): string;
  reset(): void;
}

Type-First Imports

import type { InboxItem, Category } from '@/types';
import { isProcessed, createInboxItem } from '@/types';

Export Patterns

Barrel Exports

// src/middleware/index.ts
export { validateMessage } from './validation';
export { errorHandler } from './errors';
export { securityHeaders } from './security';
export { sessionMiddleware } from './session';

Public API (index.ts)

// src/index.ts - Clean public API
export { createEngine } from './engine';
export type { Engine, Persona, Rule } from './types';
export { classicPersona, supportivePersona } from './personas';

Module Patterns

ESM Throughout

// package.json
{
  "type": "module"
}
// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

NodeNext applies to code Node runs or publishes; bundled apps use bundler (CGD-01).

Path Aliases

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Apps only. tsc does not rewrite paths aliases in emitted JavaScript, so a published library (A7) that uses them ships unresolvable imports. Libraries use relative imports with .js extensions.

Error Handling

Library APIs follow CGD-05: return Result<T, E> for expected failures, throw a named Error subclass for caller bugs. The Eliza pattern below is HTTP middleware, where a thrown error carrying a status code is the framework’s convention.

Structured Errors (Eliza)

export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string
  ) {
    super(message);
    this.name = 'AppError';
  }
}
 
// Usage in middleware
if (!isValidMessage(input)) {
  throw new AppError(400, 'Message must be a non-empty string');
}

Error Boundaries (React)

interface InboxState {
  items: InboxItem[];
  isLoaded: boolean;
  saveError: boolean; // Track storage errors
}

Security Patterns

Input Validation (Eliza)

const MAX_MESSAGE_LENGTH = 1000;
const repeatedChar = /(.)\1{500,}/; // ReDoS protection
 
export function validateMessage(req: Request, res: Response, next: NextFunction) {
  const { message } = req.body;
 
  if (typeof message !== 'string') {
    return next(new AppError(400, 'Message must be a string'));
  }
 
  if (message.length > MAX_MESSAGE_LENGTH) {
    return next(new AppError(400, `Message exceeds ${MAX_MESSAGE_LENGTH} characters`));
  }
 
  if (repeatedChar.test(message)) {
    return next(new AppError(400, 'Message contains suspicious patterns'));
  }
 
  next();
}

Security Headers

app.use(helmet());
app.use(express.json({ limit: '10kb' }));

React Patterns

Custom Hooks

export function useInbox() {
  const context = useContext(InboxContext);
  if (!context) {
    throw new Error('useInbox must be used within InboxProvider');
  }
  return context;
}

Memoization

const unprocessedItems = useMemo(
  () => state.items.filter((item) => !isProcessed(item)),
  [state.items]
);
 
const handleAdd = useCallback(
  (content: string) => {
    dispatch({ type: 'ADD', content });
  },
  [dispatch]
);

Testing Conventions

Vitest Configuration

// vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'node', // or 'jsdom' for React
    setupFiles: ['./tests/setup.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      exclude: ['**/*.d.ts'],
    },
  },
});

Test Helpers

// Deterministic testing with injected RNG
function makeRng(values: number[]): () => number {
  let index = 0;
  return () => values[index++ % values.length];
}
 
// Factory for test personas
function makePersona(rules: Rule[]): Persona {
  return { name: 'test', rules, greeting: 'Hello' };
}

Arrange-Act-Assert

describe('createEngine', () => {
  it('should start in greeting state', () => {
    // Arrange
    const persona = makePersona([]);
 
    // Act
    const engine = createEngine(persona);
 
    // Assert
    expect(engine.getState()).toBe('greeting');
  });
});

Code Quality Checklist

  • Discriminated unions for action types
  • Type-first imports separated from value imports
  • Barrel exports for clean module APIs
  • Path aliases configured (@/*)
  • ESM module format throughout
  • Input validation at boundaries
  • Custom hooks for shared logic
  • Memoization for expensive computations
  • Test helpers extracted and reusable