TypeScript Idioms and Patterns
TS type system = documentation + test + specification. Encode domain invariants so invalid states are unrepresentable. Lean into the compiler.
Scope: TS-specific type system and language idioms. Quality commands: GEMINI.md § Code Completion Mandate. Logging:
@.gemini/skills/logging-and-observability-principles/SKILL.md.
Framework Detection
When working on .ts/.tsx files, detect the project framework from imports and load the corresponding idiom skill:
| Import Pattern | Framework | Load Skill |
|---|---|---|
from 'vue', from 'pinia', from 'vue-router', from '@vueuse/*' |
Vue 3 | @.gemini/skills/vue-idioms/SKILL.md |
from 'react', from 'react-dom', from '@reduxjs/*', from 'zustand' |
React | @.gemini/skills/react-idioms/SKILL.md |
from '@angular/core', from '@angular/*' |
Angular | @.gemini/skills/angular-idioms/SKILL.md |
from 'next', from 'next/*' |
Next.js | @.gemini/skills/nextjs-idioms/SKILL.md |
Framework-specific file types (.vue, .jsx) auto-activate their skills directly. This table covers .ts/.tsx files that use framework APIs (composables, hooks, stores, services).
Strict Mode — Non-Negotiable
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
Never disable per-file without // STRICT-DISABLE: rationale comment.
Type System
unknownoverany— always:// ✅ Forces narrowing function parse(data: unknown): User { if (!isUser(data)) throw new Error('Invalid user shape'); return data; } // ❌ Disables type checker function parse(data: any): User { return data; }readonlyfor immutability:interface TaskState { readonly id: string; readonly items: readonly Task[]; } function process(tasks: readonly Task[]): Summary { ... }Discriminated unions for state machines:
type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; function render(state: AsyncState<User>): string { switch (state.status) { case 'idle': return 'Waiting...'; case 'loading': return 'Loading...'; case 'success': return state.data.name; case 'error': return state.error.message; } }Const assertions for literal types:
const ROLES = ['admin', 'editor', 'viewer'] as const; type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'Type guards over
ascasts:// ✅ Safe narrowing function isError(value: unknown): value is Error { return value instanceof Error; } // ❌ Bypasses type checker const err = value as Error;Never non-null assertion
!in production:// ❌ Hides null/undefined bug const name = user!.profile!.name; // ✅ Explicit handling const name = user?.profile?.name ?? 'Anonymous';satisfiesfor type-checked literals (TS 4.9+):const config = { endpoint: '/api/tasks', retries: 3, } satisfies ApiConfig; // config.retries typed as `3` (literal), not `number`interfacefor object shapes,typefor unions/primitives:// ✅ interface — object shapes, extendable, better error messages interface User { id: string; name: string; role: Role; } // ✅ type — unions, intersections, primitives, mapped types type Role = 'admin' | 'editor' | 'viewer'; type Nullable<T> = T | null; type EventHandler = (event: Event) => void;Why
interfacefor shapes: TypeScript surfaces the interface name in error messages (not an expanded inline blob), IDE hover shows the name not the expansion, and declaration merging enables incremental extension. For AI agents specifically,interfaceproduces more predictable, scannable type diagnostics.
Null Safety
??over||for defaults —??only falls back for null/undefined,||also for 0, '', false.const count = input.count ?? 0; // ✅ const count = input.count || 0; // ❌Optional chaining
?.for safe navigation:user?.address?.cityundefined= absence,null= intentionally empty (JSON APIs).
Async/Await
General async: GEMINI.md § Concurrency and Threading Mandate. TS-specific below.
Always
awaitor handle Promises — no floating promises:// ❌ Fire-and-forget — errors swallowed sendEmail(user); // ✅ Awaited await sendEmail(user); // ✅ Intentional fire-and-forget void sendEmail(user); // logs errors internallyPromise.allfor concurrent independent ops:const [user, tasks] = await Promise.all([getUser(id), getTasks(id)]);Promise.allSettledfor partial failure tolerance:const results = await Promise.allSettled(notifications.map(send)); const failed = results.filter(r => r.status === 'rejected');Never mix async/await with raw
.then()/.catch()in same fn.
Runtime Validation at Boundaries
All data crossing system boundary validated at runtime, not just typed.
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['low', 'medium', 'high']),
dueDate: z.string().datetime().optional(),
});
type CreateTaskRequest = z.infer<typeof CreateTaskSchema>;
function parseCreateTask(body: unknown): CreateTaskRequest {
return CreateTaskSchema.parse(body);
}
- Use
zodfor runtime validation at API ingress/egress. - Never use
asas substitute for runtime validation. - Validate on ingress; trust validated types thereafter.
Centralized HTTP Client
All outbound HTTP MUST go through shared API client utility. No direct fetch()/axios() in feature code.
// ❌ Bypass: no auth, no correlation-ID, no logging
const res = await fetch('/api/tasks');
// ✅ Shared client
import { apiFetch } from '@/infrastructure/apiFetch';
const res = await apiFetch('/api/tasks');
Why: consistent auth injection, correlation-ID propagation, centralized error normalization, single retry/timeout/logging point.
Exception: centralized client itself may use raw fetch internally.
Direct
fetch/axiosoutside shared client =[INT]audit finding.
Module and Export Patterns
Named exports over default:
// ✅ Explicit, refactor-safe, IDE-friendly export function createTask() { ... } export type { Task }; // ❌ Ambiguous import names export default function createTask() { ... }Avoid barrel re-exports creating circular deps. Feature
index.tsonly for public API.Import type separately:
import type { Task } from './types';
Testing
Test naming, file conventions, pyramid: GEMINI.md § Testing Strategy. TS-specific tooling below.
Type mocks with Vitest types — never
as any:import { vi } from 'vitest'; import type { MockedObject } from 'vitest'; const mockStore: MockedObject<TaskStore> = { create: vi.fn(), getById: vi.fn(), };Assert error types, not just messages:
await expect(service.create(invalid)).rejects.toThrow(ZodError);satisfiesfor type-checked fixtures:const fixture = { id: 'abc', title: 'Test task' } satisfies Task;
Formatting and Static Analysis
| Tool | Purpose | Notes |
|---|---|---|
vue-tsc --noEmit |
Full type checking (incl .vue) |
tsc --noEmit for non-Vue |
eslint |
Lint + style | Use @typescript-eslint/recommended-type-checked |
prettier |
Canonical formatting | Non-negotiable |
npm audit / pnpm audit |
Dependency CVE scanning | Fail on high severity |
See GEMINI.md § Code Completion Mandate for exact commands.
Related
- Code Idioms and Conventions GEMINI.md § Code Idioms and Conventions
- Vue Idioms and Patterns @.gemini/skills/vue-idioms/SKILL.md
- Testing Strategy GEMINI.md § Testing Strategy
- Error Handling Principles GEMINI.md § Error Handling Principles
- Concurrency and Threading Mandate GEMINI.md § Concurrency and Threading Mandate
- Security Principles GEMINI.md § Security Principles
- Dependency Management Principles @.gemini/skills/dependency-management-principles/SKILL.md