feat(skills): port reviewed TypeScript patterns onto current catalog

Carry forward AbhiramMandala’s canonical TypeScript guidance from PR #3142, reviewed source head 643f61d604. Rebuild current-main counts, Pi projection and trigger digest without replacing existing registry entries.

Use bracket access under the documented noPropertyAccessFromIndexSignature baseline. Type-check and execute the actual fixture, compare published function bodies, and preserve deterministic undefined, sparse-array and concurrency regressions.

Validation: 26 focused checks; strict TypeScript compile; all repository validators; affected ESLint and Markdownlint; deterministic Pi rebuild; real npm pack; disposable Claude, Codex and Copilot install/repair/uninstall with exact skill bytes and preserved user files. Parent independently reviewed the complete staged diff.

Co-authored-by: AbhiramMandala <abhirammandala02@gmail.com>
This commit is contained in:
haelyraandAbhiramMandala committed 2026-10-29 17:02:32 -07:00
1 parent 91b5f6a371
commit f29e33271b
24 files changed
+1608 -26

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
{
"name": "ecc",
"source": "./",
"description": "Harness-native ECC operator layer - 71 agents, 313 skills, 98 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
"description": "Harness-native ECC operator layer - 71 agents, 314 skills, 98 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
"version": "2.2.3",
"author": {
"name": "Affaan Mustafa",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ecc",
"version": "2.2.3",
"description": "Harness-native ECC plugin for engineering teams - 71 agents, 313 skills, 98 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
"description": "Harness-native ECC plugin for engineering teams - 71 agents, 314 skills, 98 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
"author": {
"name": "Affaan Mustafa",
"url": "https://x.com/affaanmustafa"
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides Gemini CLI with the baseline ECC workflow, review standards,
## Overview
ECC is a cross-harness coding system with 71 specialized agents, 313 skills, and 98 commands.
ECC is a cross-harness coding system with 71 specialized agents, 314 skills, and 98 commands.
Gemini support is currently focused on a strong project-local instruction layer via `.gemini/GEMINI.md`, plus the shared MCP catalog and package-manager setup assets shipped by the installer.
+2 -2
View File
@@ -1,6 +1,6 @@
# ECC — Agent Instructions
This is a **production-ready AI coding plugin** providing 71 specialized agents, 313 skills, 98 commands, and automated hook workflows for software development.
This is a **production-ready AI coding plugin** providing 71 specialized agents, 314 skills, 98 commands, and automated hook workflows for software development.
**Version:** 2.2.3
@@ -202,7 +202,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
```
agents/ — 71 specialized subagents
skills/ — 313 workflow skills and domain knowledge
skills/ — 314 workflow skills and domain knowledge
commands/ — 98 slash commands
hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language)
+3 -3
View File
@@ -141,12 +141,12 @@ Instead of rebuilding that process in every prompt, you install it once and make
ECC is MIT-licensed open source. It works best with Claude Code today, has native Codex and Qoder plugin paths, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity.
Access to 71 agents, 313 skills, and 98 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
Access to 71 agents, 314 skills, and 98 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
| Included | Count | What it gives you |
| ---------------- | ----------: | ------------------------------------------------------------------------------------ |
| Agents | 71 agents | Planning, review, build repair, security, architecture, and domain work |
| Skills | 313 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
| Skills | 314 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
| Commands | 98 commands | Convenient entry points while ECC moves to a skills-first surface |
| Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls |
| Rules | Selective | Always-loaded standards you choose by language or project |
@@ -861,7 +861,7 @@ Stable graduation of the 2.0 line: control-pane substrate, worktree lifecycle se
```text
ECC/
|-- agents/ # 71 specialized subagents for delegation
|-- skills/ # 313 reusable workflows loaded on demand
|-- skills/ # 314 reusable workflows loaded on demand
|-- commands/ # 98 maintained slash-command shims
|-- rules/ # opt-in common and language standards
|-- hooks/ # runtime automation and enforcement
+1 -1
View File
@@ -200,7 +200,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc
```
**完成!** 你现在可以使用 71 个代理、313 个技能和 98 个命令。
**完成!** 你现在可以使用 71 个代理、314 个技能和 98 个命令。
### multi-* 命令需要额外配置
+1 -1
View File
@@ -1,7 +1,7 @@
# Soul
## Core Identity
ECC is a production-ready AI coding plugin with 71 specialized agents, 313 skills, 98 commands, and automated hook workflows for software development.
ECC is a production-ready AI coding plugin with 71 specialized agents, 314 skills, 98 commands, and automated hook workflows for software development.
## Core Principles
1. **Agent-First** — route work to the right specialist as early as possible.
+1
View File
@@ -173,6 +173,7 @@ skills:
- test-collaboration
- token-budget-advisor
- token-card
- typescript-patterns
- unified-memory
- verification-loop
- video-editing
+1 -1
View File
@@ -117,7 +117,7 @@ jest --ci # Tests (Jest)
## Reference
This repo does not yet ship a dedicated `typescript-patterns` skill. For detailed TypeScript and JavaScript patterns, use `coding-standards` plus `frontend-patterns` or `backend-patterns` based on the code being reviewed.
For detailed TypeScript and JavaScript patterns, use the `typescript-patterns` skill, plus `coding-standards` and `frontend-patterns` or `backend-patterns` based on the code being reviewed.
---
+1 -1
View File
@@ -117,7 +117,7 @@ jest --ci # Pruebas (Jest)
## Referencia
Este repositorio aún no incluye una skill `typescript-patterns` dedicada. Para patrones detallados de TypeScript y JavaScript, usar `coding-standards` más `frontend-patterns` o `backend-patterns` según el código que se está revisando.
Para patrones detallados de TypeScript y JavaScript, usar la skill `typescript-patterns`, más `coding-standards` y `frontend-patterns` o `backend-patterns` según el código que se está revisando.
---
+2 -2
View File
@@ -1,6 +1,6 @@
# ECC — Agent Talimatları
Bu, yazılım geliştirme için 71 özel agent, 313 skill, 98 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
Bu, yazılım geliştirme için 71 özel agent, 314 skill, 98 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
**Sürüm:** 2.2.3
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
```
agents/ — 71 özel subagent
skills/ — 313 iş akışı skillleri ve alan bilgisi
skills/ — 314 iş akışı skillleri ve alan bilgisi
commands/ — 98 slash command
hooks/ — Tetikleyici tabanlı otomasyonlar
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
+2 -2
View File
@@ -1,6 +1,6 @@
# ECC — 智能体指令
这是一个**生产就绪的 AI 编码插件**,提供 71 个专业代理、313 项技能、98 条命令以及自动化钩子工作流,用于软件开发。
这是一个**生产就绪的 AI 编码插件**,提供 71 个专业代理、314 项技能、98 条命令以及自动化钩子工作流,用于软件开发。
**版本:** 2.2.3
@@ -147,7 +147,7 @@
```
agents/ — 71 个专业子代理
skills/ — 313 个工作流技能和领域知识
skills/ — 314 个工作流技能和领域知识
commands/ — 98 个斜杠命令
hooks/ — 基于触发的自动化
rules/ — 始终遵循的指导方针(通用 + 每种语言)
+3 -3
View File
@@ -264,7 +264,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc
```
**搞定!** 你现在可以使用 71 个智能体、313 项技能和 98 个命令了。
**搞定!** 你现在可以使用 71 个智能体、314 项技能和 98 个命令了。
***
@@ -1178,7 +1178,7 @@ opencode
|---------|---------------|----------|--------|
| 智能体 | PASS: 71 个 | PASS: 12 个 | **Claude Code 领先** |
| 命令 | PASS: 98 个 | PASS: 35 个 | **Claude Code 领先** |
| 技能 | PASS: 313 项 | PASS: 37 项 | **Claude Code 领先** |
| 技能 | PASS: 314 项 | PASS: 37 项 | **Claude Code 领先** |
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
@@ -1286,7 +1286,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|---------|-----------------------|------------|-----------|----------|
| **智能体** | 71 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
| **命令** | 98 | 共享 | 基于指令 | 35 |
| **技能** | 313 | 共享 | 10 (原生格式) | 37 |
| **技能** | 314 | 共享 | 10 (原生格式) | 37 |
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
+19 -5
View File
@@ -1,16 +1,16 @@
{
"coverage": {
"skills": 313,
"withTriggers": 33
"skills": 314,
"withTriggers": 34
},
"generatedAt": "2026-09-24T23:51:22.784Z",
"generatedAt": "2026-10-30T00:01:34.569Z",
"id": "skill-triggers@1",
"model": {
"effort": null,
"id": "hand-seeded",
"source": "manual-curation-pending-regeneration"
},
"registryDigest": "2ef30dd7ba28f6cfc0bdb8bc3588bf7050f9f089f9232f66899456261e8209c5",
"registryDigest": "419796f816a24c54058c5e6ad7aceff60fdc93ee0061d498ef19ff226d93f712",
"schemaVersion": 1,
"triggers": {
"skill:agent-runtime-gateway-smoke-test": [
@@ -283,6 +283,20 @@
"regression test first",
"write a regression test"
],
"skill:typescript-patterns": [
"strict typescript compiler configuration",
"fix cascading type errors",
"remove any and tighten types",
"design discriminated unions",
"narrow unknown at api boundary",
"review typescript pull request",
"refactor to strict mode",
"generics for caller dependent types",
"async error handling patterns",
"validate config with type guards",
"bounded concurrency mapper",
"module boundary exports"
],
"skill:verification-loop": [
"pre pr checks",
"verification report",
@@ -291,5 +305,5 @@
"before creating a pr"
]
},
"triggersDigest": "054898acb769893c46e5bd7037141f7be83fb775515ea1dbb52fb1c18aca19e6"
"triggersDigest": "953728cb51ef7726f8a4969efecaec294d37772fcddf8544cd9ee45bff4945ee"
}
+1
View File
@@ -238,6 +238,7 @@
"skills/springboot-patterns",
"skills/springboot-tdd",
"skills/springboot-verification",
"skills/typescript-patterns",
"skills/ui-to-vue",
"skills/vue-patterns",
"skills/accessibility",
+1
View File
@@ -162,6 +162,7 @@
"swiftui-patterns",
"tdd-workflow",
"test-collaboration",
"typescript-patterns",
"verification-loop",
"vite-patterns",
"vue-patterns"
+1
View File
@@ -385,6 +385,7 @@
"skills/terminal-ops/",
"skills/token-budget-advisor/",
"skills/token-card/",
"skills/typescript-patterns/",
"skills/ui-demo/",
"skills/ui-to-vue/",
"skills/unified-memory/",
+1 -1
View File
@@ -1,6 +1,6 @@
# Curation
pi/core includes 133 of 313 skills and 24 of 98 commands from the root of ECC.
pi/core includes 134 of 314 skills and 24 of 98 commands from the root of ECC.
Everything excluded is listed here with its reason.
## Rules
+1 -1
View File
@@ -1,6 +1,6 @@
# ecc-pi-core
A curated, Pi-native profile of ECC: 133 portable
A curated, Pi-native profile of ECC: 134 portable
engineering skills and 24 pure prompt-workflow commands, with no extensions,
no hooks, no runtime downloads, and no network or SaaS dependencies.
+579
View File
@@ -0,0 +1,579 @@
---
name: typescript-patterns
description: TypeScript patterns for strict, type-safe, maintainable code in modern TypeScript and JavaScript applications. Covers strict compiler configuration, type design, narrowing, discriminated unions, generics, async and error handling, module boundaries, dependency hygiene, and type-level testing. Use when writing or reviewing TypeScript or JavaScript code.
metadata:
origin: ECC
---
# TypeScript Development Patterns
TypeScript patterns and best practices for building robust, type-safe, and maintainable applications. The compiler is most useful when it can prove your program's shape, so the guiding principle here is: push the type system to do real work instead of fighting it. This skill is the "how." For the "what" and "when" (the decisions about which pattern to reach for), see the TypeScript rules — `rules/typescript/coding-style.md` and `rules/typescript/patterns.md` in this repository, installed as `rules/ecc/typescript/coding-style.md` and `rules/ecc/typescript/patterns.md`.
## When to Activate
- Writing new TypeScript or JavaScript code
- Reviewing a PR that touches `.ts` or `.tsx` files
- Refactoring existing TypeScript to remove `any`, tighten types, or add strictness
- Designing types, generics, or module boundaries for a new feature
- Setting up a new TypeScript project or upgrading `tsconfig.json`
- Debugging type errors that cascade through a codebase (usually a design problem, not an annotation problem)
## Core Principles
### 1. Strict Mode Is the Baseline
`strict: true` (which enables `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, and the rest) is not optional in a healthy codebase. If a codebase cannot compile under strict, that is a migration task, not an excuse to leave the flag off.
```jsonc
// tsconfig.json — a strict, discriminating baseline
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true
}
}
```
`noUncheckedIndexedAccess` makes array and record access possibly-undefined, which surfaces a whole class of runtime crashes at compile time. If it produces too much noise, this is the pattern to add, not the flag to remove:
```typescript
// If you have proven the index exists, lift the element into a local first.
const [first] = items
if (first === undefined) {
throw new Error("expected at least one item")
}
```
### 2. `any` Is a Bug, Not a Tool
`any` disables the type system exactly where a bug is most likely to hide: at the boundary with untrusted or dynamic data. Prefer `unknown` and narrow it. When you truly need a fallback, prefer an explicit loosening with a comment over a silent `any`.
```typescript
// Bad: any disables checking on the public API
export function parseConfig(raw: any): Config {
return raw.config ?? {}
}
interface Config {
timeout: number
retries: number
}
const DEFAULT_CONFIG: Config = { timeout: 30_000, retries: 3 }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function toPositiveInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new TypeError(`Expected ${field} to be a positive integer`)
}
return value
}
// Good: unknown forces safe narrowing at the edge. Every consumed field is
// validated with an explicit range, and defaults are declared up front.
export function parseConfig(raw: unknown): Config {
if (!isRecord(raw)) return { ...DEFAULT_CONFIG }
return {
timeout: raw['timeout'] === undefined ? DEFAULT_CONFIG.timeout : toPositiveInt(raw['timeout'], 'timeout'),
retries: raw['retries'] === undefined ? DEFAULT_CONFIG.retries : toPositiveInt(raw['retries'], 'retries'),
}
}
```
### 3. Public APIs Get Explicit Types, Locals Get Inference
TypeScript infers local variables well. Exporting functions whose parameter and return types are implicit leaks inference decisions into every consumer and makes intent unreadable.
```typescript
// Bad: exported API without explicit types
export function formatUser(user) {
return `${user.firstName} ${user.lastName}`
}
// Good: explicit public contract, inferred internals
interface User {
firstName: string
lastName: string
}
export function formatUser(user: User): string {
return `${user.firstName} ${user.lastName}`
}
```
## Type Design
### Interfaces vs. Type Aliases
`interface` for object shapes that may be extended or implemented; `type` for unions, intersections, tuples, mapped types, and utility types. Prefer string-literal unions over `enum` unless you need runtime value enumeration or interop with code that requires it.
```typescript
interface User {
id: string
email: string
}
type UserRole = 'admin' | 'member'
type AdminUser = User & { role: 'admin' }
```
### Discriminated Unions Are the Default State Machine
When a value can be in multiple shapes, model it as a discriminated union with a literal `type`/`kind` field. Exhaustive checking then becomes a free compiler feature: never a runtime fallback, always a compile error on a missing branch.
```typescript
type Transaction =
| { kind: 'draft'; id: string }
| { kind: 'submitted'; id: string; submittedAt: Date }
| { kind: 'paid'; id: string; paidAt: Date; amountCents: number }
function describe(t: Transaction): string {
switch (t.kind) {
case 'draft':
return 'Saved but not sent'
case 'submitted':
return `Submitted on ${t.submittedAt.toISOString()}`
case 'paid':
return `Paid ${t.amountCents} cents on ${t.paidAt.toISOString()}`
default:
// exhaustive: any new member forces this to fail to compile
return assertNever(t)
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled member: ${JSON.stringify(value)}`)
}
```
### Brands and Nominal Safety Where Structure Is Not Enough
Structural typing cannot tell `CustomerId` from `string`. When an ID or unit must not be confused with another, add a brand (a phantom field with a unique symbol key).
```typescript
declare const brand: unique symbol
export type CustomerId = string & { [brand]: 'CustomerId' }
function ensureCustomerId(value: string): CustomerId {
if (!/^cus_[a-z0-9]+$/.test(value)) {
throw new TypeError(`Invalid customer id: ${value}`)
}
return value as CustomerId
}
```
Keep brands internal to a boundary module; never expose the cast helper far from the validation that justifies it.
### Generics Model Caller-Dependent Types
If a value's type depends on what the caller supplies, make it generic rather than flattening to `any` or `unknown` and hand-casting at call sites.
```typescript
// Bad: caller must cast the result
export function getCached(key: string): unknown {
return cache.get(key)
}
// Good: the caller's type flows through
export function getCached<T>(key: string, parse: (raw: string) => T): T | undefined {
const raw = cache.get(key)
if (raw === undefined) return undefined
return parse(raw)
}
```
Constrain generics so the contract is discoverable: prefer `T extends SomeShape` over free-floating `T` when a shape assumption exists.
### Options Objects over Positional Parameters
Functions with more than a couple of knobs should take a single options object. It makes call sites self-documenting and lets new knobs be added without positional churn.
```typescript
interface FetchOptions {
timeoutMs?: number
retries?: number
signal?: AbortSignal
}
export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise<Response> {
// ...
}
```
## Narrowing and Runtime Safety
### Validate at the Boundary, Trust Inside
Untrusted data enters at I/O boundaries: API handlers, message consumers, file loaders. Parse and validate once with a schema, then carry the refined type through the domain. This is where a validation library (for example, Zod) earns its keep — the schema is both the runtime check and the compile-time type.
```typescript
import { z } from 'zod'
const userInputSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150)
})
export type UserInput = z.infer<typeof userInputSchema>
// validate once at the edge
const input: UserInput = userInputSchema.parse(rawBody)
```
### Type Predicates and Assertion Functions
Prefer type predicates and assertion functions over unchecked casts when guarding shapes. The guard documents the invariant and the compiler checks its usage.
```typescript
function isConfig(value: unknown): value is Config {
if (typeof value !== 'object' || value === null) return false
if (!('timeout' in value) || !('retries' in value)) return false
const { timeout, retries } = value
return (
typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0 &&
typeof retries === 'number' && Number.isInteger(retries) && retries > 0
)
}
```
To fail loudly on a malformed value instead of returning a boolean, use an assertion signature:
```typescript
function assertIsStringArray(value: unknown): asserts value is string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
throw new TypeError('Expected an array of strings')
}
}
```
### Readonly by Default
Mark array and object parameters `readonly`/`ReadonlyArray` when the function does not mutate them. It prevents accidental mutation and signals intent at the signature. (Per the coding-style rules, prefer the spread operator for immutable updates.)
```typescript
export function totalAmount(items: readonly LineItem[]): number {
return items.reduce((sum, item) => sum + item.amountCents, 0)
}
```
Runtime immutability (for example, `Object.freeze`) is for hot application boundaries; `readonly` in type positions is the everyday guard.
## Error Handling
### Fail at the Boundary, Handle at the Edge
The `try/catch` belongs where the caller can act on failure — not wrapped around every internal call in a stack of swallowed errors. Use async/await with `try/catch` and narrow `unknown` errors safely (per the coding-style rules).
```typescript
type User = { id: string; email: string }
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
if (!('id' in value) || !('email' in value)) return false
const { id, email } = value
return (
typeof id === 'string' && id.length > 0 &&
typeof email === 'string' && email.includes('@')
)
}
async function loadUser(userId: string): Promise<User> {
const response = await fetch(`/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to load user ${userId}: ${response.status}`)
}
const data: unknown = await response.json()
if (!isUser(data)) {
throw new TypeError(`Invalid user payload for ${userId}`)
}
return data
}
export async function getUser(userId: string): Promise<User> {
try {
return await loadUser(userId)
} catch (error: unknown) {
if (error instanceof Error) {
// known failure mode: retry or surface a friendly message
throw new Error(`user lookup failed: ${error.message}`, { cause: error })
}
throw new TypeError('unknown error shape')
}
}
```
### Result Types for Expected Failures
For expected business failures (validation, not-found, conflict), a discriminated `Result` union makes failure explicit in the type instead of hiding it in the exception flow.
```typescript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
function createUser(input: UserInput): Result<User, ValidationIssue[]> {
const issues = validate(input)
if (issues.length > 0) {
return { ok: false, error: issues }
}
return { ok: true, value: { id: generateId(), ...input } }
}
```
Reserve exceptions for the unexpected. Expected outcomes belong in the return type.
### Preserve Error Context
Use `new Error(message, { cause })` (available since ES2022) so the original failure chain survives rewrapping. Never stringify errors into a message and lose the cause.
## Async Patterns
### Prefer `Promise.allSettled` for Independent Fan-Out
When results are independent and one failure must not drop the others, use `Promise.allSettled` and handle the per-promise outcomes.
```typescript
const results: PromiseSettledResult<number>[] = await Promise.allSettled(
sourceIds.map((id) => fetchCount(id))
)
const total = results.reduce((sum, result) => {
if (result.status === 'fulfilled') {
return sum + result.value
}
log.warn('count fetch failed', result.reason)
return sum
}, 0)
```
Use `Promise.all` when you want fail-fast semantics — genuinely all-or-nothing work.
### Avoid Unbounded Concurrency
Fanning out hundreds of promises at once overwhelms connections and event loop. Bound the concurrency with a small pool.
```typescript
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError(`Expected concurrency limit to be a positive integer, got: ${limit}`)
}
const results: R[] = new Array(items.length)
let nextIndex = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const index = nextIndex++
if (index >= items.length) return
// Required under noUncheckedIndexedAccess: the index is proven
// in-bounds above, but the compiler still types this T | undefined.
// Skip only genuine sparse-array holes, mirroring Array.prototype.map:
// a present `undefined` element is a legitimate value and must still
// reach the mapper.
if (!Object.hasOwn(items, index)) continue
// Presence is proven above, so the indexed access is in-bounds and
// filled — never a hole and never the compiler's speculative undefined.
results[index] = await mapper(items[index]!)
}
})
// One worker rejecting rejects the whole pool: Promise.all propagates the
// first rejection to the caller (fail-fast). That is rejection, not
// cancellation — the other workers keep running and may settle more of
// their remaining items after the pool has already rejected. Use
// Promise.all only when leaving that in-flight work running is acceptable;
// otherwise use allSettled or thread an AbortSignal through the mapper.
await Promise.all(workers)
return results
}
```
### Timeouts and Cancellation
Always thread `AbortSignal` through long-running or outbound work so callers can cancel; pair it with `Promise.race` for hard timeouts where the underlying operation cannot be aborted.
```typescript
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(reason) => {
clearTimeout(timer)
reject(reason)
}
)
})
}
```
## Null and Undefined Discipline
Prefer a discriminated approach over mixing sentinel values. Where "not found" or "absent" is a legitimate state, use `T | undefined` and check explicitly; avoid deeply nesting `?.` chains followed by silent fallbacks that hide bugs.
```typescript
function findUser(id: string): User | undefined {
return users.get(id)
}
const user = findUser('u_1')
if (user === undefined) {
return notFound()
}
// from here on, `user` is narrowed to User
```
Avoid truthiness checks that silently accept wrong types (`if (result)` when `result` may be `0` or `''`). Compare against `undefined`/`null` explicitly.
## Module Boundaries and Exports
### Small, Named Modules
Expose a deliberate surface at each module's boundary. Prefer explicit named exports over default exports, and re-export at the public edge so consumers import from the module that owns the type — not from deep internals that can be refactored.
```typescript
// user/model.ts — internal types
export interface UserAccount {
id: string
email: string
role: UserRole
}
// user/index.ts — public surface
export { type UserAccount, type UserRole } from './model'
export { createUser, findUser, updateUser } from './service'
```
### Avoid Barrel Bloat and Circular Imports
Do not create barrels that import everything at module load (they defeat tree-shaking and grow cold-start). Import concrete paths. Circular imports signal a submission boundary that should fold upward; the type that both sides need belongs in a shared module neither imports.
### Split by Dependency Direction
Let the type graph point the way: types that cross boundaries go in a contract module; implementations import the contract; nothing imports implementations from contracts. In a node/backend codebase that means roughly: `domain` (pure types and rules) → `application` (use cases) → `infrastructure` (adapters), with dependencies pointing only inward.
## Dependencies and Build
### Diagnose, Then Add Dependencies
Prefer built-in `Array`, `Map`, `Promise`, and `Intl` over pulling in utilities for every small operation. A tiny inline function beats a dependency that must be upgraded, audited, and kept compatible. (The Go idiom "a little copying is better than a little dependency" applies to TypeScript too.)
### Runtime Validation, Not Type-of-Trust
Schemas that validate at runtime (Zod, validators, or hand-written guards) are the bridge between untrusted data and compile-time types. Library approaches differ (Zod and other schema-first libraries derive the type from the schema; validators in some ecosystem patterns are type-first with the runtime check derived). Pick one model and be consistent across the codebase.
### Keep the Compiler Incremental and Fast
Large codebases benefit from `composite: true` with project references, an `incremental` build cache, and `moduleResolution: bundler` where the bundler supports it. Slow type-checking is a workflow problem that pushes developers off the type system — invest in narrowing the checked surface over relaxing it.
## Performance
### Types Are Free, Structures Are Not
Type-level gymnastics (mapped types, conditional types, template literal types) cost nothing at runtime but can cost a lot of developer clarity. Keep complex type machinery inside boundary modules; do not export type circus acts as the public API.
### Avoid Unnecessary Object Renaming
Copying and spreading large nested objects repeatedly has real cost in hot paths. Prefer immutable updates with spread at the point of change, but avoid defensive re-copying of unchanged structures (for example, in render paths or per-message handlers).
### Memoize Idempotent Computation
Pure, expensive derivations belong behind a cache if they repeat with the same inputs. Use `Map`-based caches with explicit lifetime instead of ad-hoc global caches that never clear.
## Type-Level Testing
### Assert the Types, Not Just the Runtime
Behavior tests prove runtime behavior; dedicated type tests lock in the public API contract. A type that silently loosens is a regression.
```typescript
import { expectTypeOf } from 'vitest'
expectTypeOf<Result<User, ValidationIssue[]>>().toEqualTypeOf<
| { ok: true; value: User }
| { ok: false; error: ValidationIssue[] }
>()
```
### Use `satisfies` to Preserve Intent
`satisfies` checks a value against a shape without widening it to that shape — the ideal way to keep literal types while still validating structure.
```typescript
const statusByAction = {
new: 'open',
reopen: 'open',
close: 'closed'
} as const satisfies Record<Action, Status>
```
## TypeScript Tooling Integration
### Essential Commands
```bash
# Type-check the whole project
npx tsc --noEmit
# Watch mode for fast feedback
npx tsc --noEmit --watch
# Run tests (Vitest)
npx vitest run
# Lint with types-aware rules (typescript-eslint)
npx eslint .
# Formatting
npx prettier --write .
```
### Recommended Linter Baseline
The `typescript-eslint` recommended-type-checked ruleset is the practical default: it promotes `no-unsafe-assignment`, `no-unsafe-member-access`, `no-unsafe-argument`, and `no-unsafe-call` from style noise to compiler-like guarantees. Keep the type-aware config; if it is slow, that is a project-structure signal, not a reason to disable it.
## Anti-Patterns to Avoid
- **`any` as a speed hack** — it converts the compiler's proof into a runtime guess. Reach for `unknown` + narrowing instead.
- **`as` casts that lie** — a cast that bypasses a check you did not actually perform is where null-pointer and shape bugs are born. Validate, then cast, and keep the cast inside the module that validates.
- **Deeply nested callbacks / promise chains** — prefer async/await; flat code is narrowable code.
- **`enum` coercion bugs** — string-literal unions give you the same safety without runtime artifacts or reverse-mapping footguns.
- **Giant god types** — a single interface that models several unrelated shapes should be a discriminated union or several interfaces.
- **Optional-only option objects** — `allOptional` config types silently swallow typos at the call site; prefer required fields for the ones the function genuinely depends on.
- **Silent catch** — `catch {}` or `catch (e) { /* nothing */ }` hides failures the caller assumed were handled.
- **Mixing sync and async error paths** — an `async` function always surfaces throws as rejections, even before the first `await`. Only a non-`async` function returning a promise can throw synchronously (before returning it), forcing callers to handle two error shapes. Prefer `async` throughout so every failure is a rejection:
```typescript
async function asyncFails(): Promise<never> {
throw new Error('becomes a rejection, never a sync throw')
}
function lazyFails(): Promise<never> {
throw new Error('throws synchronously — needs try/catch AND .catch')
}
```
**Remember**: the type system is a proof tool, not a paperwork burden. When a type error resists a reasonable annotation, the design — not the compiler — is usually the thing to change. Prefer boring, explicit, well-scoped TypeScript over clever type engines.
## Related Skills
- `nestjs-patterns` — NestJS architecture patterns for TypeScript backends (modules, controllers, providers, DTO validation)
- `frontend-patterns` — React and Next.js component and state patterns
- TypeScript rules (`rules/typescript/`, installed as `rules/ecc/typescript/`) — the "what" and "when" this skill implements as the "how"
- `prisma-patterns` — database access patterns used in TypeScript applications
+579
View File
@@ -0,0 +1,579 @@
---
name: typescript-patterns
description: TypeScript patterns for strict, type-safe, maintainable code in modern TypeScript and JavaScript applications. Covers strict compiler configuration, type design, narrowing, discriminated unions, generics, async and error handling, module boundaries, dependency hygiene, and type-level testing. Use when writing or reviewing TypeScript or JavaScript code.
metadata:
origin: ECC
---
# TypeScript Development Patterns
TypeScript patterns and best practices for building robust, type-safe, and maintainable applications. The compiler is most useful when it can prove your program's shape, so the guiding principle here is: push the type system to do real work instead of fighting it. This skill is the "how." For the "what" and "when" (the decisions about which pattern to reach for), see the TypeScript rules — `rules/typescript/coding-style.md` and `rules/typescript/patterns.md` in this repository, installed as `rules/ecc/typescript/coding-style.md` and `rules/ecc/typescript/patterns.md`.
## When to Activate
- Writing new TypeScript or JavaScript code
- Reviewing a PR that touches `.ts` or `.tsx` files
- Refactoring existing TypeScript to remove `any`, tighten types, or add strictness
- Designing types, generics, or module boundaries for a new feature
- Setting up a new TypeScript project or upgrading `tsconfig.json`
- Debugging type errors that cascade through a codebase (usually a design problem, not an annotation problem)
## Core Principles
### 1. Strict Mode Is the Baseline
`strict: true` (which enables `strictNullChecks`, `noImplicitAny`, `strictFunctionTypes`, and the rest) is not optional in a healthy codebase. If a codebase cannot compile under strict, that is a migration task, not an excuse to leave the flag off.
```jsonc
// tsconfig.json — a strict, discriminating baseline
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true
}
}
```
`noUncheckedIndexedAccess` makes array and record access possibly-undefined, which surfaces a whole class of runtime crashes at compile time. If it produces too much noise, this is the pattern to add, not the flag to remove:
```typescript
// If you have proven the index exists, lift the element into a local first.
const [first] = items
if (first === undefined) {
throw new Error("expected at least one item")
}
```
### 2. `any` Is a Bug, Not a Tool
`any` disables the type system exactly where a bug is most likely to hide: at the boundary with untrusted or dynamic data. Prefer `unknown` and narrow it. When you truly need a fallback, prefer an explicit loosening with a comment over a silent `any`.
```typescript
// Bad: any disables checking on the public API
export function parseConfig(raw: any): Config {
return raw.config ?? {}
}
interface Config {
timeout: number
retries: number
}
const DEFAULT_CONFIG: Config = { timeout: 30_000, retries: 3 }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function toPositiveInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new TypeError(`Expected ${field} to be a positive integer`)
}
return value
}
// Good: unknown forces safe narrowing at the edge. Every consumed field is
// validated with an explicit range, and defaults are declared up front.
export function parseConfig(raw: unknown): Config {
if (!isRecord(raw)) return { ...DEFAULT_CONFIG }
return {
timeout: raw['timeout'] === undefined ? DEFAULT_CONFIG.timeout : toPositiveInt(raw['timeout'], 'timeout'),
retries: raw['retries'] === undefined ? DEFAULT_CONFIG.retries : toPositiveInt(raw['retries'], 'retries'),
}
}
```
### 3. Public APIs Get Explicit Types, Locals Get Inference
TypeScript infers local variables well. Exporting functions whose parameter and return types are implicit leaks inference decisions into every consumer and makes intent unreadable.
```typescript
// Bad: exported API without explicit types
export function formatUser(user) {
return `${user.firstName} ${user.lastName}`
}
// Good: explicit public contract, inferred internals
interface User {
firstName: string
lastName: string
}
export function formatUser(user: User): string {
return `${user.firstName} ${user.lastName}`
}
```
## Type Design
### Interfaces vs. Type Aliases
`interface` for object shapes that may be extended or implemented; `type` for unions, intersections, tuples, mapped types, and utility types. Prefer string-literal unions over `enum` unless you need runtime value enumeration or interop with code that requires it.
```typescript
interface User {
id: string
email: string
}
type UserRole = 'admin' | 'member'
type AdminUser = User & { role: 'admin' }
```
### Discriminated Unions Are the Default State Machine
When a value can be in multiple shapes, model it as a discriminated union with a literal `type`/`kind` field. Exhaustive checking then becomes a free compiler feature: never a runtime fallback, always a compile error on a missing branch.
```typescript
type Transaction =
| { kind: 'draft'; id: string }
| { kind: 'submitted'; id: string; submittedAt: Date }
| { kind: 'paid'; id: string; paidAt: Date; amountCents: number }
function describe(t: Transaction): string {
switch (t.kind) {
case 'draft':
return 'Saved but not sent'
case 'submitted':
return `Submitted on ${t.submittedAt.toISOString()}`
case 'paid':
return `Paid ${t.amountCents} cents on ${t.paidAt.toISOString()}`
default:
// exhaustive: any new member forces this to fail to compile
return assertNever(t)
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled member: ${JSON.stringify(value)}`)
}
```
### Brands and Nominal Safety Where Structure Is Not Enough
Structural typing cannot tell `CustomerId` from `string`. When an ID or unit must not be confused with another, add a brand (a phantom field with a unique symbol key).
```typescript
declare const brand: unique symbol
export type CustomerId = string & { [brand]: 'CustomerId' }
function ensureCustomerId(value: string): CustomerId {
if (!/^cus_[a-z0-9]+$/.test(value)) {
throw new TypeError(`Invalid customer id: ${value}`)
}
return value as CustomerId
}
```
Keep brands internal to a boundary module; never expose the cast helper far from the validation that justifies it.
### Generics Model Caller-Dependent Types
If a value's type depends on what the caller supplies, make it generic rather than flattening to `any` or `unknown` and hand-casting at call sites.
```typescript
// Bad: caller must cast the result
export function getCached(key: string): unknown {
return cache.get(key)
}
// Good: the caller's type flows through
export function getCached<T>(key: string, parse: (raw: string) => T): T | undefined {
const raw = cache.get(key)
if (raw === undefined) return undefined
return parse(raw)
}
```
Constrain generics so the contract is discoverable: prefer `T extends SomeShape` over free-floating `T` when a shape assumption exists.
### Options Objects over Positional Parameters
Functions with more than a couple of knobs should take a single options object. It makes call sites self-documenting and lets new knobs be added without positional churn.
```typescript
interface FetchOptions {
timeoutMs?: number
retries?: number
signal?: AbortSignal
}
export async function fetchWithRetry(url: string, options: FetchOptions = {}): Promise<Response> {
// ...
}
```
## Narrowing and Runtime Safety
### Validate at the Boundary, Trust Inside
Untrusted data enters at I/O boundaries: API handlers, message consumers, file loaders. Parse and validate once with a schema, then carry the refined type through the domain. This is where a validation library (for example, Zod) earns its keep — the schema is both the runtime check and the compile-time type.
```typescript
import { z } from 'zod'
const userInputSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(150)
})
export type UserInput = z.infer<typeof userInputSchema>
// validate once at the edge
const input: UserInput = userInputSchema.parse(rawBody)
```
### Type Predicates and Assertion Functions
Prefer type predicates and assertion functions over unchecked casts when guarding shapes. The guard documents the invariant and the compiler checks its usage.
```typescript
function isConfig(value: unknown): value is Config {
if (typeof value !== 'object' || value === null) return false
if (!('timeout' in value) || !('retries' in value)) return false
const { timeout, retries } = value
return (
typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0 &&
typeof retries === 'number' && Number.isInteger(retries) && retries > 0
)
}
```
To fail loudly on a malformed value instead of returning a boolean, use an assertion signature:
```typescript
function assertIsStringArray(value: unknown): asserts value is string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
throw new TypeError('Expected an array of strings')
}
}
```
### Readonly by Default
Mark array and object parameters `readonly`/`ReadonlyArray` when the function does not mutate them. It prevents accidental mutation and signals intent at the signature. (Per the coding-style rules, prefer the spread operator for immutable updates.)
```typescript
export function totalAmount(items: readonly LineItem[]): number {
return items.reduce((sum, item) => sum + item.amountCents, 0)
}
```
Runtime immutability (for example, `Object.freeze`) is for hot application boundaries; `readonly` in type positions is the everyday guard.
## Error Handling
### Fail at the Boundary, Handle at the Edge
The `try/catch` belongs where the caller can act on failure — not wrapped around every internal call in a stack of swallowed errors. Use async/await with `try/catch` and narrow `unknown` errors safely (per the coding-style rules).
```typescript
type User = { id: string; email: string }
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false
if (!('id' in value) || !('email' in value)) return false
const { id, email } = value
return (
typeof id === 'string' && id.length > 0 &&
typeof email === 'string' && email.includes('@')
)
}
async function loadUser(userId: string): Promise<User> {
const response = await fetch(`/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to load user ${userId}: ${response.status}`)
}
const data: unknown = await response.json()
if (!isUser(data)) {
throw new TypeError(`Invalid user payload for ${userId}`)
}
return data
}
export async function getUser(userId: string): Promise<User> {
try {
return await loadUser(userId)
} catch (error: unknown) {
if (error instanceof Error) {
// known failure mode: retry or surface a friendly message
throw new Error(`user lookup failed: ${error.message}`, { cause: error })
}
throw new TypeError('unknown error shape')
}
}
```
### Result Types for Expected Failures
For expected business failures (validation, not-found, conflict), a discriminated `Result` union makes failure explicit in the type instead of hiding it in the exception flow.
```typescript
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
function createUser(input: UserInput): Result<User, ValidationIssue[]> {
const issues = validate(input)
if (issues.length > 0) {
return { ok: false, error: issues }
}
return { ok: true, value: { id: generateId(), ...input } }
}
```
Reserve exceptions for the unexpected. Expected outcomes belong in the return type.
### Preserve Error Context
Use `new Error(message, { cause })` (available since ES2022) so the original failure chain survives rewrapping. Never stringify errors into a message and lose the cause.
## Async Patterns
### Prefer `Promise.allSettled` for Independent Fan-Out
When results are independent and one failure must not drop the others, use `Promise.allSettled` and handle the per-promise outcomes.
```typescript
const results: PromiseSettledResult<number>[] = await Promise.allSettled(
sourceIds.map((id) => fetchCount(id))
)
const total = results.reduce((sum, result) => {
if (result.status === 'fulfilled') {
return sum + result.value
}
log.warn('count fetch failed', result.reason)
return sum
}, 0)
```
Use `Promise.all` when you want fail-fast semantics — genuinely all-or-nothing work.
### Avoid Unbounded Concurrency
Fanning out hundreds of promises at once overwhelms connections and event loop. Bound the concurrency with a small pool.
```typescript
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError(`Expected concurrency limit to be a positive integer, got: ${limit}`)
}
const results: R[] = new Array(items.length)
let nextIndex = 0
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const index = nextIndex++
if (index >= items.length) return
// Required under noUncheckedIndexedAccess: the index is proven
// in-bounds above, but the compiler still types this T | undefined.
// Skip only genuine sparse-array holes, mirroring Array.prototype.map:
// a present `undefined` element is a legitimate value and must still
// reach the mapper.
if (!Object.hasOwn(items, index)) continue
// Presence is proven above, so the indexed access is in-bounds and
// filled — never a hole and never the compiler's speculative undefined.
results[index] = await mapper(items[index]!)
}
})
// One worker rejecting rejects the whole pool: Promise.all propagates the
// first rejection to the caller (fail-fast). That is rejection, not
// cancellation — the other workers keep running and may settle more of
// their remaining items after the pool has already rejected. Use
// Promise.all only when leaving that in-flight work running is acceptable;
// otherwise use allSettled or thread an AbortSignal through the mapper.
await Promise.all(workers)
return results
}
```
### Timeouts and Cancellation
Always thread `AbortSignal` through long-running or outbound work so callers can cancel; pair it with `Promise.race` for hard timeouts where the underlying operation cannot be aborted.
```typescript
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(reason) => {
clearTimeout(timer)
reject(reason)
}
)
})
}
```
## Null and Undefined Discipline
Prefer a discriminated approach over mixing sentinel values. Where "not found" or "absent" is a legitimate state, use `T | undefined` and check explicitly; avoid deeply nesting `?.` chains followed by silent fallbacks that hide bugs.
```typescript
function findUser(id: string): User | undefined {
return users.get(id)
}
const user = findUser('u_1')
if (user === undefined) {
return notFound()
}
// from here on, `user` is narrowed to User
```
Avoid truthiness checks that silently accept wrong types (`if (result)` when `result` may be `0` or `''`). Compare against `undefined`/`null` explicitly.
## Module Boundaries and Exports
### Small, Named Modules
Expose a deliberate surface at each module's boundary. Prefer explicit named exports over default exports, and re-export at the public edge so consumers import from the module that owns the type — not from deep internals that can be refactored.
```typescript
// user/model.ts — internal types
export interface UserAccount {
id: string
email: string
role: UserRole
}
// user/index.ts — public surface
export { type UserAccount, type UserRole } from './model'
export { createUser, findUser, updateUser } from './service'
```
### Avoid Barrel Bloat and Circular Imports
Do not create barrels that import everything at module load (they defeat tree-shaking and grow cold-start). Import concrete paths. Circular imports signal a submission boundary that should fold upward; the type that both sides need belongs in a shared module neither imports.
### Split by Dependency Direction
Let the type graph point the way: types that cross boundaries go in a contract module; implementations import the contract; nothing imports implementations from contracts. In a node/backend codebase that means roughly: `domain` (pure types and rules) → `application` (use cases) → `infrastructure` (adapters), with dependencies pointing only inward.
## Dependencies and Build
### Diagnose, Then Add Dependencies
Prefer built-in `Array`, `Map`, `Promise`, and `Intl` over pulling in utilities for every small operation. A tiny inline function beats a dependency that must be upgraded, audited, and kept compatible. (The Go idiom "a little copying is better than a little dependency" applies to TypeScript too.)
### Runtime Validation, Not Type-of-Trust
Schemas that validate at runtime (Zod, validators, or hand-written guards) are the bridge between untrusted data and compile-time types. Library approaches differ (Zod and other schema-first libraries derive the type from the schema; validators in some ecosystem patterns are type-first with the runtime check derived). Pick one model and be consistent across the codebase.
### Keep the Compiler Incremental and Fast
Large codebases benefit from `composite: true` with project references, an `incremental` build cache, and `moduleResolution: bundler` where the bundler supports it. Slow type-checking is a workflow problem that pushes developers off the type system — invest in narrowing the checked surface over relaxing it.
## Performance
### Types Are Free, Structures Are Not
Type-level gymnastics (mapped types, conditional types, template literal types) cost nothing at runtime but can cost a lot of developer clarity. Keep complex type machinery inside boundary modules; do not export type circus acts as the public API.
### Avoid Unnecessary Object Renaming
Copying and spreading large nested objects repeatedly has real cost in hot paths. Prefer immutable updates with spread at the point of change, but avoid defensive re-copying of unchanged structures (for example, in render paths or per-message handlers).
### Memoize Idempotent Computation
Pure, expensive derivations belong behind a cache if they repeat with the same inputs. Use `Map`-based caches with explicit lifetime instead of ad-hoc global caches that never clear.
## Type-Level Testing
### Assert the Types, Not Just the Runtime
Behavior tests prove runtime behavior; dedicated type tests lock in the public API contract. A type that silently loosens is a regression.
```typescript
import { expectTypeOf } from 'vitest'
expectTypeOf<Result<User, ValidationIssue[]>>().toEqualTypeOf<
| { ok: true; value: User }
| { ok: false; error: ValidationIssue[] }
>()
```
### Use `satisfies` to Preserve Intent
`satisfies` checks a value against a shape without widening it to that shape — the ideal way to keep literal types while still validating structure.
```typescript
const statusByAction = {
new: 'open',
reopen: 'open',
close: 'closed'
} as const satisfies Record<Action, Status>
```
## TypeScript Tooling Integration
### Essential Commands
```bash
# Type-check the whole project
npx tsc --noEmit
# Watch mode for fast feedback
npx tsc --noEmit --watch
# Run tests (Vitest)
npx vitest run
# Lint with types-aware rules (typescript-eslint)
npx eslint .
# Formatting
npx prettier --write .
```
### Recommended Linter Baseline
The `typescript-eslint` recommended-type-checked ruleset is the practical default: it promotes `no-unsafe-assignment`, `no-unsafe-member-access`, `no-unsafe-argument`, and `no-unsafe-call` from style noise to compiler-like guarantees. Keep the type-aware config; if it is slow, that is a project-structure signal, not a reason to disable it.
## Anti-Patterns to Avoid
- **`any` as a speed hack** — it converts the compiler's proof into a runtime guess. Reach for `unknown` + narrowing instead.
- **`as` casts that lie** — a cast that bypasses a check you did not actually perform is where null-pointer and shape bugs are born. Validate, then cast, and keep the cast inside the module that validates.
- **Deeply nested callbacks / promise chains** — prefer async/await; flat code is narrowable code.
- **`enum` coercion bugs** — string-literal unions give you the same safety without runtime artifacts or reverse-mapping footguns.
- **Giant god types** — a single interface that models several unrelated shapes should be a discriminated union or several interfaces.
- **Optional-only option objects** — `allOptional` config types silently swallow typos at the call site; prefer required fields for the ones the function genuinely depends on.
- **Silent catch** — `catch {}` or `catch (e) { /* nothing */ }` hides failures the caller assumed were handled.
- **Mixing sync and async error paths** — an `async` function always surfaces throws as rejections, even before the first `await`. Only a non-`async` function returning a promise can throw synchronously (before returning it), forcing callers to handle two error shapes. Prefer `async` throughout so every failure is a rejection:
```typescript
async function asyncFails(): Promise<never> {
throw new Error('becomes a rejection, never a sync throw')
}
function lazyFails(): Promise<never> {
throw new Error('throws synchronously — needs try/catch AND .catch')
}
```
**Remember**: the type system is a proof tool, not a paperwork burden. When a type error resists a reasonable annotation, the design — not the compiler — is usually the thing to change. Prefer boring, explicit, well-scoped TypeScript over clever type engines.
## Related Skills
- `nestjs-patterns` — NestJS architecture patterns for TypeScript backends (modules, controllers, providers, DTO validation)
- `frontend-patterns` — React and Next.js component and state patterns
- TypeScript rules (`rules/typescript/`, installed as `rules/ecc/typescript/`) — the "what" and "when" this skill implements as the "how"
- `prisma-patterns` — database access patterns used in TypeScript applications
+112
View File
@@ -0,0 +1,112 @@
// Runnable mirrors of the typescript-patterns SKILL.md examples.
// Must compile under strict + noUncheckedIndexedAccess (see tsconfig.json).
// Namespaced per example because the skill reuses names across snippets.
export namespace ConfigExample {
export interface Config {
timeout: number;
retries: number;
}
export const DEFAULT_CONFIG: Config = { timeout: 30_000, retries: 3 };
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function toPositiveInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new TypeError(`Expected ${field} to be a positive integer`);
}
return value;
}
export function parseConfig(raw: unknown): Config {
if (!isRecord(raw)) return { ...DEFAULT_CONFIG };
return {
timeout: raw['timeout'] === undefined ? DEFAULT_CONFIG.timeout : toPositiveInt(raw['timeout'], 'timeout'),
retries: raw['retries'] === undefined ? DEFAULT_CONFIG.retries : toPositiveInt(raw['retries'], 'retries'),
};
}
export function isConfig(value: unknown): value is Config {
if (typeof value !== 'object' || value === null) return false;
if (!('timeout' in value) || !('retries' in value)) return false;
const { timeout, retries } = value;
return (
typeof timeout === 'number' && Number.isInteger(timeout) && timeout > 0 &&
typeof retries === 'number' && Number.isInteger(retries) && retries > 0
);
}
}
export namespace UserExample {
export type User = { id: string; email: string };
export function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;
if (!('id' in value) || !('email' in value)) return false;
const { id, email } = value;
return (
typeof id === 'string' && id.length > 0 &&
typeof email === 'string' && email.includes('@')
);
}
export async function loadUser(
userId: string,
fetchJson: (url: string) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>,
): Promise<User> {
const response = await fetchJson(`/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to load user ${userId}: ${response.status}`);
}
const data: unknown = await response.json();
if (!isUser(data)) {
throw new TypeError(`Invalid user payload for ${userId}`);
}
return data;
}
}
export namespace ConcurrencyExample {
export async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
mapper: (item: T) => Promise<R>,
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new RangeError(`Expected concurrency limit to be a positive integer, got: ${limit}`);
}
const results: R[] = new Array(items.length);
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const index = nextIndex++;
if (index >= items.length) return;
// Skip only genuine sparse-array holes, mirroring Array.prototype.map:
// a present `undefined` element is a legitimate value and must still
// reach the mapper.
if (!Object.hasOwn(items, index)) continue;
// Presence is proven above, so the indexed access is in-bounds and
// filled — the non-null removal applies only to the compiler's
// speculative undefined under noUncheckedIndexedAccess.
results[index] = await mapper(items[index]!);
}
});
await Promise.all(workers);
return results;
}
}
export namespace AsyncErrorsExample {
export async function asyncFails(): Promise<never> {
throw new Error('becomes a rejection, never a sync throw');
}
export function lazyFails(): Promise<never> {
throw new Error('throws synchronously — needs try/catch AND .catch');
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["examples.ts"]
}
+280
View File
@@ -0,0 +1,280 @@
'use strict';
// Tests for the typescript-patterns skill: the SKILL.md examples must match
// their strict-TypeScript claims. Compile the real fixture and execute its
// emitted functions so the type checks and runtime regressions share one source.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const ts = require('typescript');
const repoRoot = path.resolve(__dirname, '..', '..');
const skillPath = path.join(repoRoot, 'skills', 'typescript-patterns', 'SKILL.md');
const skill = fs.readFileSync(skillPath, 'utf8');
let passed = 0;
let failed = 0;
const pending = [];
function test(name, fn) {
try {
const result = fn();
if (result && typeof result.then === 'function') {
pending.push(result.then(
() => { passed += 1; console.log(` ✓ ${name}`); },
error => { failed += 1; console.log(` ✗ ${name}`); console.log(` ${error.message}`); }
));
return;
}
passed += 1;
console.log(` ✓ ${name}`);
} catch (error) {
failed += 1;
console.log(` ✗ ${name}`);
console.log(` ${error.message}`);
}
}
console.log('\n=== TypeScript patterns skill tests ===\n');
// ---- Skill document claims ---------------------------------------------
test('loadUser validates instead of asserting the payload', () => {
assert.doesNotMatch(skill, /response\.json\(\) as Promise<User>/);
assert.match(skill, /function isUser\(value: unknown\): value is User/);
});
test('config example validates every consumed field', () => {
assert.doesNotMatch(skill, /value as Config/);
assert.match(skill, /toPositiveInt\(raw\['timeout'\]/);
assert.match(skill, /toPositiveInt\(raw\['retries'\]/);
assert.match(skill, /DEFAULT_CONFIG/);
});
test('concurrency example narrows indexed access', () => {
assert.doesNotMatch(skill, /mapper\(items\[index\]\)/);
assert.match(skill, /Number\.isInteger\(limit\) \|\| limit < 1/);
assert.match(skill, /noUncheckedIndexedAccess/);
});
test('async error prose states the real semantics', () => {
assert.match(skill, /always surfaces throws as rejections/);
assert.doesNotMatch(skill, /lets them escape as rejected promises inconsistently/);
});
// ---- Compile and execute the actual strict TypeScript fixture ----------
const fixturePath = path.join(repoRoot, 'tests', 'fixtures', 'typescript-patterns', 'examples.ts');
const configPath = path.join(path.dirname(fixturePath), 'tsconfig.json');
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(configPath));
const program = ts.createProgram(parsedConfig.fileNames, parsedConfig.options);
test('actual fixture compiles with the recommended index-signature baseline', () => {
assert.strictEqual(parsedConfig.options.noPropertyAccessFromIndexSignature, true);
const diagnostics = [config.error, ...parsedConfig.errors, ...ts.getPreEmitDiagnostics(program)].filter(Boolean);
assert.deepStrictEqual(diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')), []);
});
test('compiled config and concurrency bodies match the published examples', () => {
const printer = ts.createPrinter({ removeComments: true });
function bodies(source, filename) {
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true);
const found = new Map();
function visit(node) {
if (ts.isFunctionDeclaration(node) && node.name && node.body) {
found.set(node.name.text, printer.printNode(ts.EmitHint.Unspecified, node.body, file));
}
ts.forEachChild(node, visit);
}
visit(file);
return found;
}
const documented = [...skill.matchAll(/```typescript\n([\s\S]*?)```/g)].map(match => match[1]).join('\n');
const docs = bodies(documented, 'skill.ts');
const fixtures = bodies(fs.readFileSync(fixturePath, 'utf8'), fixturePath);
for (const name of ['parseConfig', 'mapWithConcurrency']) {
assert.ok(docs.has(name), `${name} is missing from the skill`);
assert.strictEqual(fixtures.get(name), docs.get(name), `${name} fixture drifted from the published example`);
}
});
const emitted = ts.transpileModule(fs.readFileSync(fixturePath, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
const fixtureExports = {};
// Only checked-in fixture code is evaluated; no filesystem or network access.
new Function('exports', emitted)(fixtureExports);
const { parseConfig } = fixtureExports.ConfigExample;
const { loadUser } = fixtureExports.UserExample;
const { mapWithConcurrency } = fixtureExports.ConcurrencyExample;
const okFetch = payload => async () => ({ ok: true, status: 200, json: async () => payload });
const badFetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
// ---- parseConfig --------------------------------------------------------
test('parseConfig accepts a valid configuration', () => {
assert.deepStrictEqual(parseConfig({ timeout: 1000, retries: 5 }), { timeout: 1000, retries: 5 });
});
test('parseConfig rejects a string-valued retries field', () => {
assert.throws(() => parseConfig({ timeout: 1000, retries: '3' }), TypeError);
});
test('parseConfig applies declared defaults for missing fields', () => {
assert.deepStrictEqual(parseConfig({}), { timeout: 30_000, retries: 3 });
assert.deepStrictEqual(parseConfig(null), { timeout: 30_000, retries: 3 });
});
test('parseConfig rejects zero and negative values', () => {
assert.throws(() => parseConfig({ timeout: 0, retries: 3 }), TypeError);
assert.throws(() => parseConfig({ timeout: 1000, retries: -1 }), TypeError);
assert.throws(() => parseConfig({ timeout: 1.5, retries: 3 }), TypeError);
});
// ---- loadUser / isUser ----------------------------------------------------
test('loadUser returns a valid user', async () => {
const user = await loadUser('u_1', okFetch({ id: 'u_1', email: 'a@example.com' }));
assert.deepStrictEqual(user, { id: 'u_1', email: 'a@example.com' });
});
test('loadUser rejects a response missing email', async () => {
await assert.rejects(loadUser('u_1', okFetch({ id: 'u_1' })), TypeError);
});
test('loadUser rejects fields with incorrect types', async () => {
await assert.rejects(loadUser('u_1', okFetch({ id: 1, email: 'a@example.com' })), TypeError);
await assert.rejects(loadUser('u_1', okFetch({ id: 'u_1', email: 'not-an-email' })), TypeError);
});
test('loadUser rejects null, arrays, and other malformed payloads', async () => {
await assert.rejects(loadUser('u_1', okFetch(null)), TypeError);
await assert.rejects(loadUser('u_1', okFetch([{ id: 'u_1', email: 'a@example.com' }])), TypeError);
await assert.rejects(loadUser('u_1', okFetch('oops')), TypeError);
});
test('loadUser rejects failed fetches and invalid JSON', async () => {
await assert.rejects(loadUser('u_1', badFetch), /Failed to load user/);
await assert.rejects(loadUser('u_1', async () => { throw new Error('bad json'); }), /bad json/);
});
// ---- mapWithConcurrency ----------------------------------------------------
test('mapWithConcurrency handles an empty input array', async () => {
assert.deepStrictEqual(await mapWithConcurrency([], 4, async x => x), []);
});
test('mapWithConcurrency maps with a valid limit', async () => {
assert.deepStrictEqual(await mapWithConcurrency([1, 2, 3], 2, async x => x * 2), [2, 4, 6]);
});
test('mapWithConcurrency accepts limits greater than the input length', async () => {
assert.deepStrictEqual(await mapWithConcurrency([1, 2], 10, async x => x + 1), [2, 3]);
});
test('mapWithConcurrency rejects zero and negative limits', async () => {
await assert.rejects(mapWithConcurrency([1], 0, async x => x), RangeError);
await assert.rejects(mapWithConcurrency([1], -2, async x => x), RangeError);
});
test('mapWithConcurrency rejects fractional and non-finite limits', async () => {
await assert.rejects(mapWithConcurrency([1], 1.5, async x => x), RangeError);
await assert.rejects(mapWithConcurrency([1], NaN, async x => x), RangeError);
await assert.rejects(mapWithConcurrency([1], Infinity, async x => x), RangeError);
});
test('mapWithConcurrency maps a present undefined element', async () => {
const seen = [];
const out = await mapWithConcurrency([undefined, 1], 2, async x => {
seen.push(x);
return x === undefined ? 0 : x;
});
assert.deepStrictEqual(seen, [undefined, 1]);
assert.deepStrictEqual(out, [0, 1]);
});
test('mapWithConcurrency skips only genuine sparse-array holes', async () => {
// Index 1 is a real hole; index 0 holds a present `undefined` value.
const input = new Array(3);
input[0] = undefined;
input[2] = 3;
const seen = [];
const out = await mapWithConcurrency(input, 3, async x => {
seen.push(x);
return x === undefined ? 'was-undefined' : x;
});
// The hole never reaches the mapper; the present undefined does.
assert.deepStrictEqual(seen, [undefined, 3]);
assert.strictEqual(out[0], 'was-undefined');
assert.strictEqual(out[2], 3);
assert.ok(!(1 in out), 'hole at index 1 should stay a hole in the result, not become a value');
assert.strictEqual(out.length, 3);
});
function deferred() {
let release;
const promise = new Promise(resolve => { release = resolve; });
return { promise, release };
}
test('mapWithConcurrency preserves order when mappers complete out of their input order', async () => {
const gates = [deferred(), deferred(), deferred(), deferred()];
const started = [];
const completed = [];
const resultPromise = mapWithConcurrency([0, 1, 2, 3], 4, async x => {
started.push(x);
await gates[x].promise;
completed.push(x);
return x * 10;
});
// No gate has been released yet, so having every mapper started proves
// concurrency happened before any completion.
await Promise.resolve();
assert.deepStrictEqual(started, [0, 1, 2, 3]);
// Release the gates in reverse input order and record the actual
// completion order; determinism comes from the gates, not timers.
for (let i = 3; i >= 0; i -= 1) {
gates[i].release();
}
const out = await resultPromise;
assert.deepStrictEqual(completed, [3, 2, 1, 0]);
// The completion order differs from input order, yet results still land
// in input order.
assert.deepStrictEqual(out, [0, 10, 20, 30]);
});
test('mapWithConcurrency propagates mapper rejection', async () => {
await assert.rejects(
mapWithConcurrency([1, 2, 3], 2, async x => { if (x === 2) throw new Error('boom'); return x; }),
/boom/
);
});
test('mapWithConcurrency never exceeds the configured limit', async () => {
let active = 0;
let maxActive = 0;
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async x => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise(resolve => setTimeout(resolve, 5));
active -= 1;
return x;
});
assert.ok(maxActive <= 2, `peak concurrency ${maxActive} exceeded limit 2`);
assert.ok(maxActive > 1, 'workers never overlapped; pool may be serial');
});
test('async functions surface throws as rejections, sync returns do not', async () => {
const { asyncFails, lazyFails } = fixtureExports.AsyncErrorsExample;
await assert.rejects(asyncFails(), /becomes a rejection/);
assert.throws(() => lazyFails(), /throws synchronously/);
});
Promise.allSettled(pending).then(() => {
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
});