From 996e1809f44308130363f252843209290894f274 Mon Sep 17 00:00:00 2001 From: doistbot Date: Fri, 28 Aug 2026 15:56:12 +0000 Subject: [PATCH] chore: Update TypeScript development guidelines This file is automatically synced from the `shared-configs` repository. Source: https://github.com/doist/shared-configs/blob/main/ --- docs/typescript-guidelines/async.md | 33 ++++++ docs/typescript-guidelines/conventions.md | 59 +++++++++ docs/typescript-guidelines/errors.md | 131 ++++++++++++++++++++ docs/typescript-guidelines/philosophy.md | 17 +++ docs/typescript-guidelines/testing.md | 58 +++++++++ docs/typescript-guidelines/types.md | 138 ++++++++++++++++++++++ 6 files changed, 436 insertions(+) create mode 100644 docs/typescript-guidelines/async.md create mode 100644 docs/typescript-guidelines/conventions.md create mode 100644 docs/typescript-guidelines/errors.md create mode 100644 docs/typescript-guidelines/philosophy.md create mode 100644 docs/typescript-guidelines/testing.md create mode 100644 docs/typescript-guidelines/types.md diff --git a/docs/typescript-guidelines/async.md b/docs/typescript-guidelines/async.md new file mode 100644 index 000000000..f53c73410 --- /dev/null +++ b/docs/typescript-guidelines/async.md @@ -0,0 +1,33 @@ +# Async Patterns + +## Promise Handling + +Always handle rejections. Use `async`/`await` over `.then()` chains. Never fire-and-forget a promise. + +```typescript +// Good: async/await with error handling +async function loadProject(id: string): Promise { + try { + const response = await api.getProject(id) + return response.data + } catch (error: unknown) { + if (error instanceof ApiError) { + logger.error('Failed to load project', { id, error }) + } + throw error + } +} + +// Bad: unhandled promise +function loadProject(id: string) { + api.getProject(id) // no await, no .catch() +} + +// Bad: void promise without handling +void fetchData() // fire-and-forget +``` + +## Rules + +- **No fire-and-forget promises** - Every promise must be awaited, returned, or have its rejection handled +- **Handle all three states** - Loading, error, and success for every async operation diff --git a/docs/typescript-guidelines/conventions.md b/docs/typescript-guidelines/conventions.md new file mode 100644 index 000000000..224cc4c2c --- /dev/null +++ b/docs/typescript-guidelines/conventions.md @@ -0,0 +1,59 @@ +# Coding Conventions + +## Naming Conventions + +| Type | Convention | Example | +| ------------------ | ------------------------ | ------------------- | +| Files | kebab-case | `task-list.tsx` | +| Components | PascalCase | `TaskList` | +| Hooks | camelCase + `use` prefix | `useTaskList` | +| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | +| Functions | camelCase | `filterActiveTasks` | +| Types / Interfaces | PascalCase | `TaskItemProps` | + +## Function Style + +- Use **function declarations** for named, top-level functions and components +- Use **arrow functions** for anonymous callbacks and inline functions +- Keep functions small and focused on a single responsibility +- Use early returns to reduce nesting + +```typescript +// Good: function declaration for component +function TaskList({ tasks }: TaskListProps) { + return ( + + ) +} + +// Good: arrow function for callback +const activeTasks = tasks.filter((task) => !task.isCompleted) + +// Good: early returns +function handleSubmit(event: React.FormEvent): void { + event.preventDefault() + + if (!isValid) { + showValidationErrors() + return + } + + if (!user) { + redirectToLogin() + return + } + + submitForm() +} +``` + +## Code Organization + +- Group related functions together +- Place helper functions before the main function/component +- Place constants at the top of the file +- Don't use `index.ts` files for exports only diff --git a/docs/typescript-guidelines/errors.md b/docs/typescript-guidelines/errors.md new file mode 100644 index 000000000..f2e113720 --- /dev/null +++ b/docs/typescript-guidelines/errors.md @@ -0,0 +1,131 @@ +# Error Handling + +Fix errors at the source. Never suppress warnings without a clear technical reason. + +## Rules + +- **Always `catch (error: unknown)`** - Never assume the type of a caught error +- **Narrow before using** - Use `instanceof` or type guards to identify error types +- **Early returns over nesting** - Guard clauses reduce cognitive load +- **Log once at the boundary** - Don't log the same error at multiple layers +- **Fix, don't suppress** - Structural fixes (proper types, refactoring) over `eslint-disable` or `@ts-ignore` + +## Validating Error Types + +Always type catch parameters as `unknown` and narrow before accessing properties. + +```typescript +try { + await saveProject(project) +} catch (error: unknown) { + if (error instanceof ApiError) { + logger.error('Failed to save project', { code: error.code, message: error.message }) + showErrorToast(error.userMessage) + return + } + + if (error instanceof TypeError) { + logger.error('Type error saving project', { error }) + return + } + + logger.error('Unexpected error saving project', { error }) +} +``` + +## Custom Error Types + +Use discriminated unions with a `kind` field for domain-specific error handling. + +```typescript +type ApiResult = + | { kind: 'success'; data: T } + | { kind: 'validation_error'; fields: Record } + | { kind: 'not_found' } + | { kind: 'network_error'; retryable: boolean } + +function handleApiResult(result: ApiResult) { + switch (result.kind) { + case 'success': + return result.data + case 'validation_error': + showFieldErrors(result.fields) + return + case 'not_found': + navigateTo404() + return + case 'network_error': + if (result.retryable) { + scheduleRetry() + } + return + } +} +``` + +## Early Returns + +Flatten nested conditionals into guard clauses. + +```typescript +// Good: early returns +function processTask(task: Task | null, user: User | null): void { + if (!task) { + return + } + + if (!user) { + redirectToLogin() + return + } + + if (!user.canEdit(task)) { + showPermissionError() + return + } + + openEditor(task) +} + +// Bad: deeply nested +function processTask(task: Task | null, user: User | null): void { + if (task) { + if (user) { + if (user.canEdit(task)) { + openEditor(task) + } else { + showPermissionError() + } + } else { + redirectToLogin() + } + } +} +``` + +## Error Tracking (Sentry) + +Never import from `@sentry/*` directly. Use the project's `logger` module which wraps Sentry with consistent fingerprinting and context. + +```typescript +// Good: use project logger +import { logger, captureInfo } from 'src/logger/logger' + +logger.error('Sync failed', { error, projectId }) +captureInfo('Feature flag fallback used', { flagName }) + +// Bad: direct Sentry import +import * as Sentry from '@sentry/react' +Sentry.captureException(error) +``` + +When adding error context, include structured data that aids debugging: + +```typescript +logger.error('Task update failed', { + error, + taskId: task.id, + projectId: task.projectId, + action: 'complete', +}) +``` diff --git a/docs/typescript-guidelines/philosophy.md b/docs/typescript-guidelines/philosophy.md new file mode 100644 index 000000000..49b2b3f16 --- /dev/null +++ b/docs/typescript-guidelines/philosophy.md @@ -0,0 +1,17 @@ +# TypeScript Philosophy + +## Core Principles + +- **Strict types, zero `any`** - The type system is your first line of defense. Never use `any`; use `unknown` when the type is genuinely not known. +- **Minimal and simple** - Prefer the simplest solution that works. Three similar lines are better than a premature abstraction. +- **Self-documenting** - Precise naming and strong types replace most comments. Code should read like prose. +- **Fix errors, don't suppress** - Fix linter and type errors at the root. Never `@ts-ignore`; use `@ts-expect-error` with explanation only when truly unavoidable. +- **Performance-conscious** - Memoize expensive computations, avoid unnecessary recomputation, virtualize long lists. +- **Accessible by default** - Semantic HTML, ARIA attributes, keyboard navigation, and sufficient contrast in every interface. + +## What This Means + +- A new developer can understand any component by reading its types and props +- Type errors caught at compile time never reach users +- Refactoring is safe because the compiler catches breakage +- Code reviews focus on logic and architecture, not formatting or type correctness diff --git a/docs/typescript-guidelines/testing.md b/docs/typescript-guidelines/testing.md new file mode 100644 index 000000000..089585e9a --- /dev/null +++ b/docs/typescript-guidelines/testing.md @@ -0,0 +1,58 @@ +# Testing + +## Strategy + +- **Unit tests** - Pure logic: utilities, helpers, transformations. No rendering. +- **Component tests** - See [react-guidelines/testing.md](../react-guidelines/testing.md) for React Testing Library patterns. +- **Hook tests** - `renderHook` with wrappers for Router/Redux context. +- **Integration tests** - MSW to mock network requests at the service worker level. +- **Keep suites lean** - Test observable behavior, not implementation details. + +## File Naming + +Test files are colocated with their source files: + +``` +task-list.tsx +task-list.test.tsx +use-task-filters.ts +use-task-filters.test.ts +``` + +## API Mocking with MSW + +Mock at the network level using MSW. The server is set up globally in test framework setup — don't call `mswServer.listen()` or `mswServer.close()` in individual tests. + +```typescript +import { http, HttpResponse } from 'msw' +import { mswServer } from 'src/mocks/msw-server' + +test('displays projects from API', async () => { + mswServer.use( + http.get('/api/projects', () => { + return HttpResponse.json([ + { id: '1', name: 'Work' }, + { id: '2', name: 'Personal' }, + ]) + }), + ) + + render() + + expect(await screen.findByText('Work')).toBeInTheDocument() + expect(screen.getByText('Personal')).toBeInTheDocument() +}) +``` + +## Rules + +- **Mock at network level** - Use MSW, not function mocks on API modules +- **No `mswServer.listen()` / `mswServer.close()`** - MSW is set up globally; use `mswServer.use()` for per-test overrides +- **One assertion focus per test** - Each test should verify one behavior, though multiple `expect` calls for that behavior are fine + +## CI + +```bash +npm run check # TypeScript + ESLint + Biome +npm run test -- --ci # Jest with CI reporter +``` diff --git a/docs/typescript-guidelines/types.md b/docs/typescript-guidelines/types.md new file mode 100644 index 000000000..da73e2781 --- /dev/null +++ b/docs/typescript-guidelines/types.md @@ -0,0 +1,138 @@ +# Type System + +## Interfaces vs Types + +Use **interfaces** for object shapes that may be extended. Use **type aliases** for unions and complex types. When extending an interface, prefer `interface extends` over `&` intersections — it produces clearer error messages and is faster for the TypeScript compiler. + +```typescript +// Interface: extendable object shape +interface Task { + id: string + content: string + isCompleted: boolean + dueDate?: Date + labels: string[] +} + +// Type alias: union +type TaskPriority = 1 | 2 | 3 | 4 + +// Extending an interface (preferred over intersection for TS performance) +interface TaskWithProject extends Task { + projectId: string +} +``` + +## Enums + +Avoid TypeScript enums. They add runtime code, have surprising type behavior, and are not erasable. Use one of these alternatives instead: + +**String literal unions** (preferred for small sets): + +```typescript +type Theme = 'light' | 'dark' +type ProjectView = 'list' | 'board' | 'calendar' +``` + +**`as const` objects** (when you need runtime access to the values): + +```typescript +const ProjectView = { + List: 'list', + Board: 'board', + Calendar: 'calendar', +} as const + +type ProjectView = (typeof ProjectView)[keyof typeof ProjectView] + +// Runtime access: Object.values(ProjectView), ProjectView.List, etc. +``` + +## Generics + +Use generics for reusable functions and components. Choose meaningful parameter names. + +```typescript +function filterItems( + items: TItem[], + property: TKey, + value: TItem[TKey], +): TItem[] { + return items.filter((item) => item[property] === value) +} +``` + +## Type Guards and Discriminated Unions + +Use custom `is` predicates for runtime type narrowing. Use `kind` or `type` discriminants for union types. + +```typescript +// Custom type guard +function isTask(value: unknown): value is Task { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + 'content' in value && + 'isCompleted' in value + ) +} + +// Discriminated union +type Result = + | { kind: 'success'; data: T } + | { kind: 'error'; error: Error } + | { kind: 'loading' } + +function handleResult(result: Result) { + switch (result.kind) { + case 'success': + return result.data + case 'error': + throw result.error + case 'loading': + return null + } +} +``` + +## Readonly and Immutability + +Use `readonly` for properties that should not change after creation. Use `Readonly` and `ReadonlyArray` for stricter immutability guarantees. + +```typescript +interface UserSettings { + readonly id: string + theme: 'light' | 'dark' + notifications: boolean +} + +function processItems(items: ReadonlyArray): Task[] { + // items.push() would be a compile error + return items.filter((item) => !item.isCompleted) +} +``` + +## Nullish Handling + +Use `??` over `||` for defaults (avoids falsy traps with `0`, `''`, `false`). Use optional chaining for nested access. + +```typescript +// Good: nullish coalescing +const pageSize = config.pageSize ?? 25 + +// Bad: logical OR treats 0 as falsy +const pageSize = config.pageSize || 25 + +// Good: optional chaining +const city = user?.address?.city +``` + +## Rules + +- **No `any`** - Use `unknown` and narrow with type guards +- **No `@ts-ignore`** - Use `@ts-expect-error` with a comment explaining why, only when unavoidable +- **Use `satisfies`** - Validate object literals against types without widening: `const config = { ... } satisfies Config` +- **Prefer inference** - Don't annotate return types when the compiler can infer them and the result is obvious +- **Use `as const`** - For literal types and readonly arrays: `const PRIORITIES = [1, 2, 3, 4] as const` +- **Use `unknown` in catch** - Always `catch (error: unknown)`, then narrow with `instanceof`