Merge pull request 'backend-refactoring' (#1) from backend-refactoring into main

Reviewed-on: #1
This commit is contained in:
2026-09-14 14:00:02 +00:00
493 changed files with 56269 additions and 537 deletions

View File

@@ -0,0 +1,192 @@
---
name: better-auth-best-practices
description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration.
---
# Better Auth Integration Guide
## Documentation Version
Use documentation that matches the Better Auth version installed in the project. APIs and plugin names can differ across maintained release lines.
1. Prefer a version explicitly named by the user.
2. Otherwise, inspect the resolved `better-auth` version in the lockfile, falling back to the package manifest when no lockfile is available.
3. When the Better Auth MCP is available, call `get_doc` with `/llms.txt` to resolve that package version to a documentation identifier. Pass the identifier to every `search_docs` call and pass result paths to `get_doc` unchanged.
4. Without MCP, start at [better-auth.com/llms.txt](https://better-auth.com/llms.txt) and follow the matching version index.
5. Use the latest documentation only when the project version cannot be determined or the user explicitly asks about the latest release or an upgrade.
When planning an upgrade, separate guidance for the currently installed version from guidance for the target version.
---
## Setup Workflow
1. Install: `npm install better-auth`
2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`
3. Create `auth.ts` with database + config
4. Create route handler for your framework
5. Run migrations:
- **Built-in adapter:** `npx auth@latest migrate`
- **Drizzle:** `npx auth@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` (dev) or `npx drizzle-kit generate && npx drizzle-kit migrate` (prod)
- **Prisma:** `npx auth@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev`
6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }`
---
## Quick Reference
### Environment Variables
- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32`
- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`)
Only define `baseURL`/`secret` in config if env vars are NOT set.
### File Location
CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path.
### CLI Commands
- `npx auth@latest migrate` - Apply schema (built-in adapter)
- `npx auth@latest generate` - Generate schema for Prisma/Drizzle
- `npx auth@latest mcp --cursor` - Add MCP to AI tools
**Re-run after adding/changing plugins.**
---
## Core Config Options
| Option | Notes |
|--------|-------|
| `appName` | Optional display name |
| `baseURL` | Only if `BETTER_AUTH_URL` not set |
| `basePath` | Default `/api/auth`. Set `/` for root. |
| `secret` | Only if `BETTER_AUTH_SECRET` not set |
| `database` | Required for most features. See adapters docs. |
| `secondaryStorage` | Redis/KV for sessions & rate limits |
| `emailAndPassword` | `{ enabled: true }` to activate |
| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` |
| `plugins` | Array of plugins |
| `trustedOrigins` | CSRF whitelist |
---
## Database
**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. For Postgres, also supports `postgres` (postgres.js) and `@neondatabase/serverless`.
**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`.
**Drizzle provider values:** `"pg"` (PostgreSQL), `"mysql"` (MySQL), `"sqlite"` (SQLite). Must match the driver used.
**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`.
---
## Session Management
**Storage priority:**
1. If `secondaryStorage` defined → sessions go there (not DB)
2. Set `session.storeSessionInDatabase: true` to also persist to DB
3. No database + `cookieCache` → fully stateless mode
**Cookie cache strategies:**
- `compact` (default) - Base64url + HMAC. Smallest.
- `jwt` - Standard JWT. Readable but signed.
- `jwe` - Encrypted. Maximum security.
**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions).
---
## User & Account Config
**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default).
**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth).
**Required for registration:** `email` and `name` fields.
---
## Email Flows
- `emailVerification.sendVerificationEmail` - Must be defined for verification to work
- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers
- `emailAndPassword.sendResetPassword` - Password reset email handler
---
## Security
**In `advanced`:**
- `useSecureCookies` - Force HTTPS cookies
- `disableCSRFCheck` - ⚠️ Security risk
- `disableOriginCheck` - ⚠️ Security risk
- `crossSubDomainCookies.enabled` - Share cookies across subdomains
- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies
- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false`
**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage").
---
## Hooks
**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`.
**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions.
**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`.
---
## Plugins
**Import from dedicated paths for tree-shaking:**
```
import { twoFactor } from "better-auth/plugins/two-factor"
```
NOT `from "better-auth/plugins"`.
**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`.
Client plugins go in `createAuthClient({ plugins: [...] })`.
---
## Client
Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`.
Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`.
---
## Type Safety
Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`.
For separate client/server projects: `createAuthClient<typeof auth>()`.
---
## Common Gotchas
1. **Model vs table name** - Config uses ORM model name, not DB table name
2. **Plugin schema** - Re-run CLI after adding plugins
3. **Secondary storage** - Sessions go there by default, not DB
4. **Cookie cache** - Custom session fields NOT cached, always re-fetched
5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry
6. **Change email flow** - Sends to current email first, then new email
7. **Drizzle: db not initialized** - `drizzleAdapter(db, ...)` requires a `db` instance from `drizzle()`. See `create-auth` skill for setup examples (node-postgres, postgres.js, Neon).
8. **Drizzle: missing drizzle.config.ts** - `drizzle-kit` commands require a `drizzle.config.ts` pointing to the generated schema file and DB credentials.
---
## Resources
- [Docs](https://better-auth.com/docs)
- [Options Reference](https://better-auth.com/docs/reference/options)
- [LLMs.txt](https://better-auth.com/llms.txt)
- [GitHub](https://github.com/better-auth/better-auth)
- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts)

View File

@@ -0,0 +1,450 @@
# Clean Code Principles - Agent Documentation
**Version:** 1.0.2
**Focus:** SOLID Principles, Core Principles (DRY, KISS, YAGNI), Design Patterns
**Rules:** 23 (10 SOLID + 12 Core + 1 Pattern); 4 categories planned
**License:** MIT
---
This skill provides comprehensive clean code principles, SOLID guidelines, and design patterns for building maintainable, scalable software.
## Overview
The clean-code-principles skill offers language-agnostic software design principles organized into 7 categories, from CRITICAL (SOLID, Core Principles) to LOW priority (Comments). Each rule provides bad/good examples, explanations, and practical guidance.
## When to Use This Skill
Activate this skill when:
- Reviewing code architecture or design
- Refactoring existing code
- Making design decisions
- Establishing coding standards
- Teaching software design principles
- Addressing technical debt
- Improving code quality and maintainability
## Trigger Phrases
The skill activates on:
- "review architecture"
- "check code quality"
- "SOLID principles"
- "design patterns"
- "clean code"
- "refactoring advice"
- "code smells"
- "best practices"
- "DRY principle"
- "separation of concerns"
## Skill Structure
```
clean-code-principles/
├── SKILL.md # Main skill definition
├── AGENTS.md # This file - agent documentation
├── README.md # User-facing documentation
├── metadata.json # Structured metadata and references
└── rules/
├── _sections.md # Category definitions and organization
├── _template.md # Template for new rules
├── solid-*.md # SOLID principles (10 rules)
├── core-*.md # Core principles (12 rules)
└── pattern-*.md # Design patterns (1 rule)
```
## Rule Categories
### 1. SOLID Principles (CRITICAL - 10 rules)
**Prefix:** `solid-`
Five fundamental object-oriented design principles:
- **S**ingle Responsibility: `solid-srp-class`, `solid-srp-function`
- **O**pen/Closed: `solid-ocp-extension`, `solid-ocp-abstraction`
- **L**iskov Substitution: `solid-lsp-contracts`, `solid-lsp-preconditions`
- **I**nterface Segregation: `solid-isp-clients`, `solid-isp-interfaces`
- **D**ependency Inversion: `solid-dip-abstractions`, `solid-dip-injection`
**Use when:** Designing architecture, planning refactoring, discussing system design
### 2. Core Principles (CRITICAL - 12 rules)
**Prefix:** `core-`
Fundamental coding practices:
- **DRY** (Don't Repeat Yourself): 3 rules
- **KISS** (Keep It Simple): 2 rules
- **YAGNI** (You Aren't Gonna Need It): 2 rules
- **Other**: Separation of Concerns, Composition Over Inheritance, Law of Demeter, Fail Fast, Encapsulation
**Use when:** Daily coding, code reviews, addressing duplication or complexity
### 3. Design Patterns (HIGH - 1 rule)
**Prefix:** `pattern-`
Common solutions to recurring problems:
- Repository Pattern (data access abstraction)
**Use when:** Solving architectural problems, abstracting infrastructure concerns
### 4-7. Future Categories
- **Code Organization** (`org-`): Module structure, boundaries
- **Naming & Readability** (`name-`): Identifier naming conventions
- **Functions & Methods** (`func-`): Function-level best practices
- **Comments & Documentation** (`doc-`): Documentation guidelines
## How to Use Rules
### Accessing Rules
1. **By ID:** Reference specific rules using their ID
```
Check against solid-srp-class and core-dry
```
2. **By Category:** Apply all rules in a category
```
Review this class against SOLID principles
```
3. **By Scenario:** Choose relevant rules for the context
```
This has duplicated validation logic - check DRY rules
```
### Rule Format
Each rule follows a consistent structure:
```markdown
---
id: {rule-id}
title: {Full Title}
category: {category}
priority: {critical|high|medium|low}
tags: [{tags}]
related: [{related-rule-ids}]
---
# {Rule Title}
{One-sentence summary}
## Bad Example
{Anti-pattern code with problems listed}
## Good Example
{Correct implementation with benefits}
## Why
{5-7 benefits explaining the value}
## When to Apply
{Practical scenarios}
```
### Output Format
When identifying violations, use:
```
file:line - [rule-id] Description of issue
```
Example:
```
src/services/UserService.ts:15 - [solid-srp-class] Class handles validation, persistence, and notifications
src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts
src/models/Order.ts:28 - [core-kiss-simplicity] Overly complex abstraction for simple use case
```
## Agent Strategies
### Strategy 1: Architecture Review
**Goal:** Assess overall system design
**Approach:**
1. Start with SOLID principles (highest impact)
2. Identify violations of SRP, DIP, OCP
3. Check for proper separation of concerns
4. Evaluate composition vs inheritance
5. Assess interface design (ISP)
**Output:** Prioritized list of architectural issues with rule references
### Strategy 2: Code Quality Audit
**Goal:** Find code quality issues in specific files
**Approach:**
1. Scan for duplication (DRY rules)
2. Check complexity (KISS rules)
3. Look for overengineering (YAGNI rules)
4. Verify single responsibility
5. Assess encapsulation
**Output:** File-by-file findings with specific line references
### Strategy 3: Refactoring Guidance
**Goal:** Provide actionable refactoring steps
**Approach:**
1. Identify the primary issue (which rule violated)
2. Reference the good example from that rule
3. Suggest specific refactoring steps
4. Mention related rules that may also help
5. Prioritize changes by impact
**Output:** Step-by-step refactoring plan with rule references
### Strategy 4: Design Decision Support
**Goal:** Help choose between design alternatives
**Approach:**
1. Analyze each option against relevant principles
2. Consider YAGNI (simplest solution first)
3. Evaluate against SOLID principles
4. Check alignment with KISS
5. Recommend based on principle adherence
**Output:** Comparative analysis with principle-based recommendation
### Strategy 5: Pattern Application
**Goal:** Suggest appropriate design patterns
**Approach:**
1. Identify the problem being solved
2. Check if any patterns apply
3. Verify pattern aligns with SOLID principles
4. Ensure pattern isn't overengineering (YAGNI)
5. Provide implementation guidance
**Output:** Pattern recommendation with integration guidance
## Best Practices for Agents
### Do:
- Start with CRITICAL rules (SOLID, Core)
- Provide specific line numbers when possible
- Reference rule IDs in all findings
- Explain WHY a rule matters, not just that it's violated
- Give actionable refactoring suggestions
- Consider the codebase context
- Prioritize by impact (CRITICAL > HIGH > MEDIUM > LOW)
- Show code examples from the rules
- Mention related rules that apply together
### Don't:
- Don't cite every minor violation
- Don't recommend patterns without clear need (YAGNI)
- Don't ignore context (some violations may be acceptable)
- Don't focus on LOW priority rules when CRITICAL issues exist
- Don't provide fixes without explaining the principle
- Don't recommend complex solutions for simple problems (KISS)
- Don't suggest abstractions without concrete use cases (YAGNI)
### Prioritization
When multiple issues exist:
1. **CRITICAL - SOLID violations:** Architecture-level problems
2. **CRITICAL - Core violations:** Duplication, complexity, overengineering
3. **HIGH - Pattern opportunities:** Where patterns genuinely help
4. **MEDIUM - Naming/Functions:** Readability improvements
5. **LOW - Documentation:** Comment and doc issues
## Example Interactions
### Example 1: Architecture Review
**User:** "Review the architecture of this OrderService class"
**Agent Approach:**
1. Check class responsibilities against `solid-srp-class`
2. Evaluate dependencies against `solid-dip-injection`
3. Look for data access against `pattern-repository`
4. Check for proper separation against `core-separation-concerns`
**Agent Output:**
```
Architecture Review - OrderService
CRITICAL Issues:
1. [solid-srp-class] Class has multiple responsibilities:
- Order validation (should be separate validator)
- Direct database access (should use repository)
- Email sending (should be separate service)
- Payment processing (should be separate service)
2. [solid-dip-abstractions] Direct dependency on concrete implementations:
- Instantiates MySQLDatabase directly (line 15)
- Creates StripePaymentGateway in constructor (line 18)
3. [pattern-repository] Direct SQL queries in service layer:
- Lines 45-50: Raw SQL should be in repository
Recommendations:
1. Split into focused classes following SRP
2. Inject dependencies through constructor (DIP)
3. Create repository abstraction for data access
4. Extract email and payment to separate services
See: solid-srp-class.md, solid-dip-injection.md, pattern-repository.md
```
### Example 2: Code Quality Check
**User:** "Check this validation function for code quality issues"
**Agent Approach:**
1. Check for duplication (`core-dry`)
2. Check complexity (`core-kiss-simplicity`)
3. Check function responsibility (`solid-srp-function`)
**Agent Output:**
```
Code Quality Issues:
utils/validation.ts:
Line 23 - [core-dry] Email regex duplicated from line 45 and validators/email.ts
Line 30 - [core-dry-extraction] Password validation logic should be extracted to reusable function
Line 50 - [solid-srp-function] Function validates both email AND password - split into two functions
Suggested refactoring:
1. Extract common regex patterns to constants file (core-dry-single-source)
2. Create separate validateEmail() and validatePassword() functions (solid-srp-function)
3. Import from shared validators module (core-dry-extraction)
See: core-dry.md, core-dry-extraction.md, solid-srp-function.md
```
### Example 3: Design Decision
**User:** "Should I use inheritance or composition for this User/Admin relationship?"
**Agent Approach:**
1. Apply `core-composition` principle
2. Check LSP implications (`solid-lsp-contracts`)
3. Consider YAGNI (`core-yagni-abstractions`)
**Agent Output:**
```
Recommendation: Use Composition
Analysis:
1. [core-composition] Composition is preferred over inheritance for flexibility
- Admins can have additional capabilities without inheritance constraints
- Easier to test each capability independently
- Can mix different permission sets dynamically
2. [solid-lsp-contracts] Inheritance risks LSP violations
- If Admin overrides User methods differently
- If Admin has different behavior expectations
3. [core-yagni-abstractions] Start simple
- Don't create inheritance hierarchy unless you need polymorphism
- Compose from Permission and Role objects instead
Suggested approach:
```typescript
class User {
constructor(
private permissions: PermissionSet,
private roleManager: RoleManager
) {}
}
```
See: core-composition.md, solid-lsp-contracts.md
```
## Integration with Other Skills
This skill complements:
- **refactoring**: Provides principles for refactoring decisions
- **architecture-review**: Supplies SOLID and design principles
- **code-review**: Offers quality criteria for reviews
- **testing**: Promotes testable design (DIP, SRP)
## Rule Reference Quick Guide
### Most Common Rules
**For duplication:**
- `core-dry` - Main DRY principle
- `core-dry-extraction` - How to extract duplicated code
- `core-dry-single-source` - Configuration and constants
**For complex code:**
- `core-kiss-simplicity` - Avoid overengineering
- `core-kiss-readability` - Optimize for readability
- `core-yagni-features` - Don't build unused features
- `core-yagni-abstractions` - Don't abstract prematurely
**For class design:**
- `solid-srp-class` - Single responsibility for classes
- `solid-dip-injection` - Dependency injection
- `core-separation-concerns` - Separate different concerns
- `core-composition` - Favor composition over inheritance
**For function design:**
- `solid-srp-function` - Single responsibility for functions
- `core-kiss-readability` - Clear, readable functions
**For interfaces:**
- `solid-isp-interfaces` - Small, focused interfaces
- `solid-isp-clients` - Client-specific interfaces
**For extensibility:**
- `solid-ocp-extension` - Open for extension, closed for modification
- `solid-ocp-abstraction` - Use abstractions for extension points
**For inheritance:**
- `solid-lsp-contracts` - Subtypes must honor contracts
- `solid-lsp-preconditions` - Pre/postcondition rules
- `core-composition` - Prefer composition
**For data access:**
- `pattern-repository` - Abstract data persistence
## Metadata
**Version:** 1.0.2
**Rules:** 23 (10 SOLID, 12 Core, 1 Pattern)
**Categories:** 7 (3 implemented, 4 planned)
**Languages:** Language-agnostic (examples in TypeScript)
**Last Updated:** 2026-03-07
## Resources
### Books
- Clean Code (Robert C. Martin)
- Design Patterns (Gang of Four)
- Refactoring (Martin Fowler)
- The Pragmatic Programmer (Hunt & Thomas)
### Online
- [Refactoring Guru](https://refactoring.guru/) - Design patterns and code smells
- [Martin Fowler's Catalog](https://refactoring.com/catalog/) - Refactoring techniques
- [Uncle Bob's Blog](https://blog.cleancoder.com/) - Software craftsmanship
## Contributing New Rules
When adding new rules:
1. Use `rules/_template.md` as starting point
2. Follow naming convention: `{prefix}-{concept}-{specificity}.md`
3. Include YAML frontmatter with all required fields
4. Provide clear bad/good examples
5. Explain 5-7 benefits in "Why" section
6. Add to `metadata.json` rules array
7. Update category counts in `_sections.md`
8. Reference related rules in frontmatter
9. Keep examples language-agnostic (TypeScript preferred)
10. Aim for 300-400 lines of content
## License
MIT License. This skill is provided as-is for educational and development purposes.

View File

@@ -0,0 +1,88 @@
# Clean Code Principles
Fundamental software design principles for writing maintainable, scalable code.
**Version:** 1.0.2
**Rules:** 23 (10 SOLID + 12 Core + 1 Pattern); 4 categories planned
**License:** MIT
---
## Overview
Language-agnostic guidelines covering SOLID principles, core coding principles (DRY, KISS, YAGNI), and design patterns. Examples are written in TypeScript but apply to any object-oriented or functional language.
## Categories (23 rules implemented)
### 1. SOLID Principles (Critical) — 10 rules
Five fundamental object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion.
### 2. Core Principles (Critical) — 12 rules
DRY (3 rules), KISS (2 rules), YAGNI (2 rules), Separation of Concerns, Composition over Inheritance, Law of Demeter, Fail Fast, Encapsulation.
### 3. Design Patterns (High) — 1 rule
Repository pattern for data access abstraction.
### 4. Code Organization (High) — planned
Feature folders, module boundaries, layered architecture, package cohesion, circular dependency prevention.
### 5. Naming & Readability (Medium) — planned
Meaningful names, consistent conventions, no magic numbers, domain language.
### 6. Functions & Methods (Medium) — planned
Small functions, single purpose, limited parameters, pure functions, command-query separation.
### 7. Comments & Documentation (Low) — planned
Self-documenting code, explain why not what, avoid noise, document public APIs.
## Usage
Ask Claude to:
- "Review architecture" — triggers SOLID + Separation of Concerns analysis
- "Check SOLID principles" — targeted SOLID review
- "Check code quality" — DRY, KISS, YAGNI audit
- "Suggest design patterns" — pattern recommendations
- "Refactoring advice" — actionable improvements with rule references
## Key Principles
### SOLID
| Principle | Rule | Summary |
|-----------|------|---------|
| **S**ingle Responsibility | `solid-srp-class`, `solid-srp-function` | One reason to change |
| **O**pen/Closed | `solid-ocp-extension`, `solid-ocp-abstraction` | Open for extension, closed for modification |
| **L**iskov Substitution | `solid-lsp-contracts`, `solid-lsp-preconditions` | Subtypes must be substitutable |
| **I**nterface Segregation | `solid-isp-clients`, `solid-isp-interfaces` | Small, focused interfaces |
| **D**ependency Inversion | `solid-dip-abstractions`, `solid-dip-injection` | Depend on abstractions |
### Core
| Principle | Rules | Summary |
|-----------|-------|---------|
| **DRY** | `core-dry`, `core-dry-extraction`, `core-dry-single-source` | Single source of truth |
| **KISS** | `core-kiss-simplicity`, `core-kiss-readability` | Simplest solution that works |
| **YAGNI** | `core-yagni-features`, `core-yagni-abstractions` | Build only what's needed |
## Output Format
When auditing code:
```
file:line - [rule-id] Description of issue
```
Example:
```
src/services/UserService.ts:15 - [solid-srp-class] Class handles validation, persistence, and email
src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts
src/models/Order.ts:28 - [core-yagni-abstractions] Generic abstraction used in only one place
```
## References
- **Clean Code** by Robert C. Martin — Foundation for clean code practices
- **Design Patterns** by Gang of Four — Classic design pattern catalog
- **Refactoring** by Martin Fowler — Improving code structure
- **The Pragmatic Programmer** by Hunt & Thomas — Practical software wisdom
- [Refactoring Guru](https://refactoring.guru/) — Design patterns and code smells
- [Martin Fowler's Refactoring Catalog](https://refactoring.com/catalog/) — Comprehensive techniques

View File

@@ -0,0 +1,213 @@
---
name: clean-code-principles
description: SOLID principles, design patterns, DRY, KISS, and clean code fundamentals. Use when reviewing architecture, checking code quality, refactoring, or discussing design decisions. Triggers on "review architecture", "check code quality", "SOLID principles", "design patterns", or "clean code".
license: MIT
metadata:
author: AsyrafHussin
version: "1.0.2"
---
# Clean Code Principles
Fundamental software design principles, SOLID, design patterns, and clean code practices. Language-agnostic guidelines for writing maintainable, scalable software.
## When to Apply
Reference these guidelines when:
- Designing new features or systems
- Reviewing code architecture
- Refactoring existing code
- Discussing design decisions
- Improving code quality
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | SOLID Principles | CRITICAL | `solid-` |
| 2 | Core Principles | CRITICAL | `core-` |
| 3 | Design Patterns | HIGH | `pattern-` |
| 4 | Code Organization | HIGH | `org-` |
| 5 | Naming & Readability | MEDIUM | `name-` |
| 6 | Functions & Methods | MEDIUM | `func-` |
| 7 | Comments & Documentation | LOW | `doc-` |
## Quick Reference
### 1. SOLID Principles (CRITICAL)
- `solid-srp` - Single Responsibility Principle
- `solid-ocp` - Open/Closed Principle
- `solid-lsp` - Liskov Substitution Principle
- `solid-isp` - Interface Segregation Principle
- `solid-dip` - Dependency Inversion Principle
### 2. Core Principles (CRITICAL)
- `core-dry` - Don't Repeat Yourself
- `core-kiss` - Keep It Simple, Stupid
- `core-yagni` - You Aren't Gonna Need It
- `core-separation-of-concerns` - Separate different responsibilities
- `core-composition-over-inheritance` - Favor composition
- `core-law-of-demeter` - Principle of least knowledge
- `core-fail-fast` - Detect and report errors early
- `core-encapsulation` - Hide implementation details
### 3. Design Patterns (HIGH)
- `pattern-factory` - Factory pattern for object creation
- `pattern-strategy` - Strategy pattern for algorithms
- `pattern-repository` - Repository pattern for data access
- `pattern-decorator` - Decorator pattern for behavior extension
- `pattern-observer` - Observer pattern for event handling
- `pattern-adapter` - Adapter pattern for interface conversion
- `pattern-facade` - Facade pattern for simplified interfaces
- `pattern-dependency-injection` - DI for loose coupling
### 4. Code Organization (HIGH) — planned
- `org-feature-folders` - Organize by feature, not layer
- `org-module-boundaries` - Clear module boundaries
- `org-layered-architecture` - Proper layer separation
- `org-package-cohesion` - Related code together
- `org-circular-dependencies` - Avoid circular imports
### 5. Naming & Readability (MEDIUM) — planned
- `name-meaningful` - Use intention-revealing names
- `name-consistent` - Consistent naming conventions
- `name-searchable` - Avoid magic numbers/strings
- `name-avoid-encodings` - No Hungarian notation
- `name-domain-language` - Use domain terminology
### 6. Functions & Methods (MEDIUM) — planned
- `func-small` - Keep functions small
- `func-single-purpose` - Do one thing
- `func-few-arguments` - Limit parameters
- `func-no-side-effects` - Minimize side effects
- `func-command-query` - Separate commands and queries
### 7. Comments & Documentation (LOW) — planned
- `doc-self-documenting` - Code should explain itself
- `doc-why-not-what` - Explain why, not what
- `doc-avoid-noise` - No redundant comments
- `doc-api-docs` - Document public APIs
## Essential Guidelines
For detailed examples and explanations, see the rule files:
- [core-dry.md](rules/core-dry.md) - Don't Repeat Yourself principle
- [pattern-repository.md](rules/pattern-repository.md) - Repository pattern for data access
### SOLID Principles (Summary)
| Principle | Definition |
|-----------|------------|
| **S**ingle Responsibility | A class should have only one reason to change |
| **O**pen/Closed | Open for extension, closed for modification |
| **L**iskov Substitution | Subtypes must be substitutable for base types |
| **I**nterface Segregation | Don't force clients to depend on unused interfaces |
| **D**ependency Inversion | Depend on abstractions, not concretions |
### Core Principles (Summary)
| Principle | Definition |
|-----------|------------|
| **DRY** | Don't Repeat Yourself - single source of truth |
| **KISS** | Keep It Simple - avoid over-engineering |
| **YAGNI** | You Aren't Gonna Need It - build only what's needed |
### Quick Examples
```typescript
// Single Responsibility - one class, one job
class UserService {
constructor(
private validator: UserValidator,
private repository: UserRepository,
) {}
createUser(data) {
this.validator.validate(data);
return this.repository.create(data);
}
}
// Dependency Inversion - depend on abstractions
interface Repository<T> {
find(id: string): Promise<T | null>;
save(entity: T): Promise<T>;
}
class OrderService {
constructor(private repository: Repository<Order>) {}
}
// DRY - single source of truth
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = (email: string) => EMAIL_REGEX.test(email);
// Meaningful names over magic numbers
const MINIMUM_AGE = 18;
if (user.age >= MINIMUM_AGE) { }
```
## Output Format
When auditing code, output findings in this format:
```
file:line - [principle] Description of issue
```
Example:
```
src/services/UserService.ts:15 - [solid-srp] Class handles validation, persistence, and notifications
src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts
src/models/Order.ts:28 - [name-meaningful] Variable 'x' should describe its purpose
```
## How to Use
Read individual rule files for detailed explanations:
```
rules/solid-srp-class.md
rules/core-dry.md
rules/pattern-repository.md
```
## References
This skill is built on established software engineering principles:
### Core Books
- **Clean Code** by Robert C. Martin - Foundation for clean code practices
- **Design Patterns** by Gang of Four - Classic design pattern catalog
- **Refactoring** by Martin Fowler - Improving code structure
- **The Pragmatic Programmer** by Hunt & Thomas - Practical wisdom
### Online Resources
- [Refactoring Guru](https://refactoring.guru/) - Design patterns and code smells
- [Martin Fowler's Refactoring Catalog](https://refactoring.com/catalog/) - Comprehensive refactoring techniques
- [Uncle Bob's Clean Coder Blog](https://blog.cleancoder.com/) - Software craftsmanship articles
### Pattern Catalogs
- [Refactoring Guru - Design Patterns](https://refactoring.guru/design-patterns)
- [Martin Fowler - Enterprise Patterns](https://martinfowler.com/eaaCatalog/)
## Metadata
**Version:** 1.0.2
**Status:** Active
**Coverage:** 23 rules across 3 implemented categories (SOLID, Core Principles, Design Patterns); 4 planned
**Last Updated:** 2026-03-07
### Rule Statistics
- SOLID Principles: 10 rules
- Core Principles: 12 rules
- Design Patterns: 1 rule

View File

@@ -0,0 +1,320 @@
# Clean Code Principles - Rule Categories
This document defines the organizational structure for clean code principles, ordered by priority and impact.
## Category Overview
| Priority | Category | Impact | Rule Count | Prefix |
|----------|----------|--------|------------|--------|
| 1 | SOLID Principles | CRITICAL | 10 | `solid-` |
| 2 | Core Principles | CRITICAL | 12 | `core-` |
| 3 | Design Patterns | HIGH | 1 | `pattern-` |
| 4 | Code Organization | HIGH | 0 | `org-` |
| 5 | Naming & Readability | MEDIUM | 0 | `name-` |
| 6 | Functions & Methods | MEDIUM | 0 | `func-` |
| 7 | Comments & Documentation | LOW | 0 | `doc-` |
## 1. SOLID Principles (CRITICAL)
**Priority:** CRITICAL
**Impact:** Architectural foundation, affects entire codebase structure
**Prefix:** `solid-`
The five fundamental principles of object-oriented design that guide maintainable, scalable software architecture.
### Rules
#### Single Responsibility Principle (SRP)
- `solid-srp-class` - A class should have only one reason to change
- `solid-srp-function` - A function should do one thing and do it well
#### Open/Closed Principle (OCP)
- `solid-ocp-extension` - Open for extension, closed for modification
- `solid-ocp-abstraction` - Use abstractions to enable extension
#### Liskov Substitution Principle (LSP)
- `solid-lsp-contracts` - Subtypes must honor base type contracts
- `solid-lsp-preconditions` - Cannot strengthen preconditions or weaken postconditions
#### Interface Segregation Principle (ISP)
- `solid-isp-clients` - Client-specific interfaces, not general-purpose
- `solid-isp-interfaces` - Small, cohesive interfaces
#### Dependency Inversion Principle (DIP)
- `solid-dip-abstractions` - Depend on abstractions, not concretions
- `solid-dip-injection` - Inject dependencies from outside
**Key Concepts:**
- Architectural soundness
- Maintainability at scale
- Testability through design
- Flexibility for change
- Reduced coupling
**When to Apply:**
- Designing new features or systems
- Refactoring existing architecture
- Addressing technical debt
- Improving testability
- Planning for future extensibility
---
## 2. Core Principles (CRITICAL)
**Priority:** CRITICAL
**Impact:** Daily coding practices, code quality foundation
**Prefix:** `core-`
Fundamental principles that apply to every line of code you write, regardless of paradigm or language.
### Rules
#### DRY (Don't Repeat Yourself)
- `core-dry` - Every piece of knowledge should have a single representation
- `core-dry-extraction` - Extract duplicated code into reusable functions
- `core-dry-single-source` - Single source of truth for configuration and data
#### KISS (Keep It Simple, Stupid)
- `core-kiss-simplicity` - Choose the simplest solution that works
- `core-kiss-readability` - Optimize for readability over cleverness
#### YAGNI (You Aren't Gonna Need It)
- `core-yagni-features` - Don't implement features before they're needed
- `core-yagni-abstractions` - Don't create abstractions prematurely
#### Other Core Principles
- `core-separation-concerns` - Different concerns in different modules
- `core-composition` - Favor composition over inheritance
- `core-law-demeter` - Only talk to immediate friends
- `core-fail-fast` - Detect and report errors early
- `core-encapsulation` - Hide implementation details
**Key Concepts:**
- Code duplication elimination
- Simplicity over complexity
- Lean development
- Modularity
- Information hiding
**When to Apply:**
- Writing any new code
- Code reviews
- Refactoring sessions
- Bug fixes
- Performance optimization
---
## 3. Design Patterns (HIGH)
**Priority:** HIGH
**Impact:** Solves recurring problems with proven solutions
**Prefix:** `pattern-`
Common design patterns that provide tested solutions to recurring software design problems.
### Rules
- `pattern-repository` - Abstraction for data access layer
- `pattern-factory` - Object creation without specifying exact class (planned)
- `pattern-strategy` - Encapsulate algorithms for runtime selection (planned)
- `pattern-decorator` - Add behavior without modifying objects (planned)
- `pattern-observer` - Define one-to-many dependencies (planned)
- `pattern-adapter` - Make incompatible interfaces work together (planned)
- `pattern-facade` - Simplified interface to complex subsystems (planned)
**Key Concepts:**
- Proven solutions
- Common vocabulary
- Design reusability
- Best practices codified
- Language-agnostic approaches
**When to Apply:**
- Solving common architectural problems
- Improving code structure
- Reducing coupling between components
- Making systems more testable
- Communicating design intent
---
## 4. Code Organization (HIGH)
**Priority:** HIGH
**Impact:** Project structure, module boundaries, discoverability
**Prefix:** `org-`
Principles for organizing code into modules, packages, and directories for maintainability and scalability.
### Rules (Planned)
- `org-feature-folders` - Organize by feature, not by layer
- `org-module-boundaries` - Clear boundaries between modules
- `org-layered-architecture` - Proper separation of layers
- `org-package-cohesion` - Keep related code together
- `org-circular-dependencies` - Avoid circular imports
**Key Concepts:**
- Feature-based organization
- Module boundaries
- Layer separation
- Dependency direction
- Discoverability
**When to Apply:**
- Starting new projects
- Restructuring existing codebases
- Scaling applications
- Onboarding new team members
- Managing microservices
---
## 5. Naming & Readability (MEDIUM)
**Priority:** MEDIUM
**Impact:** Code comprehension, maintenance speed
**Prefix:** `name-`
Conventions and principles for naming variables, functions, classes, and other identifiers.
### Rules (Planned)
- `name-meaningful` - Use intention-revealing names
- `name-consistent` - Follow consistent naming conventions
- `name-searchable` - Avoid magic numbers and strings
- `name-avoid-encodings` - No Hungarian notation
- `name-domain-language` - Use ubiquitous domain language
**Key Concepts:**
- Intention revelation
- Consistency
- Searchability
- Domain terminology
- Avoid abbreviations
**When to Apply:**
- Creating new identifiers
- Refactoring unclear names
- Code reviews
- Domain modeling
- API design
---
## 6. Functions & Methods (MEDIUM)
**Priority:** MEDIUM
**Impact:** Code readability, testability at function level
**Prefix:** `func-`
Principles for writing clean, focused functions and methods.
### Rules (Planned)
- `func-small` - Keep functions small and focused
- `func-single-purpose` - Do one thing only
- `func-few-arguments` - Limit function parameters
- `func-no-side-effects` - Minimize or document side effects
- `func-command-query` - Separate commands from queries
**Key Concepts:**
- Small functions
- Single purpose
- Few parameters
- Pure functions when possible
- Predictable behavior
**When to Apply:**
- Writing new functions
- Refactoring long methods
- Improving testability
- Code reviews
- Performance optimization
---
## 7. Comments & Documentation (LOW)
**Priority:** LOW
**Impact:** Code maintainability, knowledge transfer
**Prefix:** `doc-`
Guidelines for when and how to use comments and documentation effectively.
### Rules (Planned)
- `doc-self-documenting` - Write code that explains itself
- `doc-why-not-what` - Comments should explain why, not what
- `doc-avoid-noise` - No redundant or obvious comments
- `doc-api-docs` - Document public APIs and interfaces
**Key Concepts:**
- Self-documenting code
- Intent over implementation
- Avoid redundancy
- Public API documentation
- Living documentation
**When to Apply:**
- Complex business logic
- Non-obvious algorithms
- Public APIs
- Architectural decisions
- Workarounds and hacks
---
## Rule Naming Convention
All rules follow a consistent naming pattern:
```
{prefix}-{concept}-{specificity}
```
Examples:
- `solid-srp-class` - SOLID principle, SRP concept, class level
- `core-dry-extraction` - Core principle, DRY concept, extraction technique
- `pattern-repository` - Design pattern category, repository pattern
## Priority Levels Explained
- **CRITICAL**: Core architectural and coding principles. Violations significantly impact maintainability, testability, and scalability.
- **HIGH**: Important patterns and organizational principles. Violations complicate future development.
- **MEDIUM**: Best practices that improve code quality. Violations make code harder to read and maintain.
- **LOW**: Nice-to-have practices. Violations have minimal impact but reduce clarity.
## Impact Assessment
- **CRITICAL Impact**: Affects entire system architecture, multiple teams, long-term maintainability
- **HIGH Impact**: Affects module design, team productivity, medium-term maintainability
- **MEDIUM Impact**: Affects code readability, individual developer productivity
- **LOW Impact**: Affects code clarity, documentation quality
## Usage Guidelines
1. Start with SOLID and Core Principles - these are non-negotiable
2. Apply Design Patterns when solving specific architectural problems
3. Use Code Organization principles when structuring projects
4. Follow Naming & Readability guidelines for all new code
5. Apply Function principles during refactoring and new development
6. Add Comments only when necessary to explain complex logic
## Cross-References
Rules often relate to each other. The `related` field in each rule's frontmatter indicates:
- Rules that commonly apply together
- Rules that solve similar problems
- Rules that complement each other
- Rules that provide context or prerequisites
## Evolution
This categorization will evolve as:
- New rules are added
- Patterns emerge from practice
- Team feedback is incorporated
- Language-specific adaptations are needed

View File

@@ -0,0 +1,211 @@
---
id: {prefix}-{concept}-{specificity}
title: {Full Descriptive Title}
category: {solid-principles|core-principles|design-patterns|code-organization|naming-readability|functions-methods|comments-documentation}
priority: {critical|high|medium|low}
tags: [{tag1}, {tag2}, {tag3}, {tag4}]
related: [{rule-id-1}, {rule-id-2}, {rule-id-3}]
---
# {Rule Title}
{One or two sentence summary explaining the principle and why it matters. Should be clear and actionable.}
## Bad Example
```typescript
// Anti-pattern: {Brief description of what's wrong}
{Code example demonstrating the violation}
// Problems:
// 1. {Specific issue 1}
// 2. {Specific issue 2}
// 3. {Specific issue 3}
```
**Why This Is Wrong:**
- {Consequence 1}
- {Consequence 2}
- {Consequence 3}
## Good Example
```typescript
// Correct approach: {Brief description of the solution}
{Code example demonstrating proper implementation}
// Benefits:
// 1. {Benefit 1}
// 2. {Benefit 2}
// 3. {Benefit 3}
```
**Alternative Approach (Optional):**
```typescript
// Another valid solution: {When this might be preferred}
{Alternative code example if applicable}
```
## Why
Explanation of the principle and its benefits:
1. **{Benefit Category 1}**: {Detailed explanation}
2. **{Benefit Category 2}**: {Detailed explanation}
3. **{Benefit Category 3}**: {Detailed explanation}
4. **{Benefit Category 4}**: {Detailed explanation}
5. **{Benefit Category 5}**: {Detailed explanation}
6. **{Benefit Category 6}**: {Detailed explanation}
7. **{Benefit Category 7}**: {Detailed explanation}
## When to Apply
- {Situation 1}
- {Situation 2}
- {Situation 3}
- {Situation 4}
## When NOT to Apply (Optional)
```typescript
// Acceptable exception: {Scenario where the rule can be relaxed}
{Code example of acceptable violation with clear reasoning}
// This is acceptable because:
// - {Reason 1}
// - {Reason 2}
```
## Common Mistakes (Optional)
### Mistake 1: {Common misunderstanding}
```typescript
// ❌ Wrong
{Code showing mistake}
// ✅ Correct
{Code showing correction}
```
### Mistake 2: {Another common issue}
```typescript
// ❌ Wrong
{Code showing mistake}
// ✅ Correct
{Code showing correction}
```
## Testing Implications (Optional)
How this principle affects testing:
```typescript
// Test example showing improved testability
{Test code demonstrating benefits}
```
## Real-World Example (Optional)
{Brief description of how this applies in production scenarios}
```typescript
// Production scenario: {Description}
{Realistic code example}
```
## Related Principles
- **{Related Rule 1}**: {Brief explanation of relationship}
- **{Related Rule 2}**: {Brief explanation of relationship}
- **{Related Rule 3}**: {Brief explanation of relationship}
## Further Reading (Optional)
- {Resource title} - {URL or reference}
- {Resource title} - {URL or reference}
## Language-Specific Notes (Optional)
### TypeScript/JavaScript
{Language-specific considerations}
### Python
{Language-specific considerations}
### Java
{Language-specific considerations}
### Go
{Language-specific considerations}
---
## Template Guidelines
### Frontmatter
- **id**: Use format `{prefix}-{concept}-{specificity}`. Must be unique and match filename.
- **title**: Full descriptive title, human-readable
- **category**: One of the 7 defined categories
- **priority**: critical (SOLID, Core) | high (Patterns, Org) | medium (Naming, Functions) | low (Comments)
- **tags**: 3-5 relevant tags for searchability
- **related**: 2-4 related rule IDs that commonly apply together
### Content Structure
1. **Title & Summary**: Clear, one-sentence explanation
2. **Bad Example**: Show the anti-pattern with clear problems listed
3. **Good Example**: Show proper implementation with benefits
4. **Why**: 5-7 benefits explaining the value
5. **When to Apply**: Practical scenarios
6. **Optional Sections**: Add as needed for complex rules
### Code Examples
- Use TypeScript for primary examples (language-agnostic)
- Keep examples focused and minimal
- Show realistic scenarios, not toy examples
- Include comments explaining key points
- Use ❌ for bad examples, ✅ for good examples
### Writing Style
- Be direct and actionable
- Focus on "why" not just "what"
- Use active voice
- Keep explanations concise
- Provide context for decisions
- Assume intermediate developer knowledge
### Length Guidelines
- Minimum: 200 lines (simple rules)
- Target: 300-400 lines (most rules)
- Maximum: 600 lines (complex patterns)
### Quality Checklist
- [ ] Frontmatter complete and accurate
- [ ] Clear bad example with explained problems
- [ ] Clear good example with explained benefits
- [ ] At least 5 benefits in "Why" section
- [ ] Practical "When to Apply" scenarios
- [ ] Related rules referenced
- [ ] Code examples are realistic
- [ ] Comments explain key concepts
- [ ] Language-agnostic where possible
- [ ] Proofread for clarity and typos

View File

@@ -0,0 +1,317 @@
---
id: core-composition
title: Composition Over Inheritance
category: core-principles
priority: critical
tags: [composition, inheritance, flexibility, design]
related: [solid-srp-class, solid-dip-injection, core-encapsulation]
---
# Composition Over Inheritance
Favor composing objects from smaller, focused pieces over building deep inheritance hierarchies. Composition provides more flexibility, better encapsulation, and avoids the fragile base class problem.
## Bad Example
```typescript
// Anti-pattern: Deep inheritance hierarchy
class Animal {
protected name: string;
protected energy: number = 100;
constructor(name: string) {
this.name = name;
}
eat(amount: number): void {
this.energy += amount;
console.log(`${this.name} is eating. Energy: ${this.energy}`);
}
sleep(hours: number): void {
this.energy += hours * 10;
console.log(`${this.name} slept for ${hours} hours. Energy: ${this.energy}`);
}
}
class Bird extends Animal {
fly(): void {
this.energy -= 20;
console.log(`${this.name} is flying. Energy: ${this.energy}`);
}
}
class Duck extends Bird {
swim(): void {
this.energy -= 5;
console.log(`${this.name} is swimming. Energy: ${this.energy}`);
}
quack(): void {
console.log(`${this.name} says quack!`);
}
}
class FlyingFish extends Animal {
// Problem: Can't inherit from both Bird and Fish
// Must duplicate flying code or create awkward hierarchy
swim(): void {
this.energy -= 5;
console.log(`${this.name} is swimming. Energy: ${this.energy}`);
}
// Duplicated from Bird class!
fly(): void {
this.energy -= 20;
console.log(`${this.name} is flying. Energy: ${this.energy}`);
}
}
class Penguin extends Bird {
// Problem: Penguins can't fly but inherit fly()
// Must override to throw error - LSP violation
fly(): void {
throw new Error('Penguins cannot fly!');
}
swim(): void {
this.energy -= 5;
console.log(`${this.name} is swimming. Energy: ${this.energy}`);
}
}
// More problems:
// - What about a robot bird? It doesn't eat or sleep.
// - What about a bat? It flies but isn't a bird.
// - Every change to Animal affects all subclasses.
// - Testing requires understanding entire hierarchy.
```
## Good Example
```typescript
// Correct approach: Composition with focused behaviors
// Define behaviors as interfaces
interface Eater {
eat(amount: number): void;
}
interface Sleeper {
sleep(hours: number): void;
}
interface Flyer {
fly(): void;
}
interface Swimmer {
swim(): void;
}
interface Speaker {
speak(): void;
}
// Implement behaviors as standalone classes
class StandardEater implements Eater {
constructor(private entity: { name: string; energy: number }) {}
eat(amount: number): void {
this.entity.energy += amount;
console.log(`${this.entity.name} is eating. Energy: ${this.entity.energy}`);
}
}
class StandardSleeper implements Sleeper {
constructor(private entity: { name: string; energy: number }) {}
sleep(hours: number): void {
this.entity.energy += hours * 10;
console.log(`${this.entity.name} slept for ${hours} hours. Energy: ${this.entity.energy}`);
}
}
class WingedFlyer implements Flyer {
constructor(
private entity: { name: string; energy: number },
private energyCost: number = 20
) {}
fly(): void {
this.entity.energy -= this.energyCost;
console.log(`${this.entity.name} is flying. Energy: ${this.entity.energy}`);
}
}
class AquaticSwimmer implements Swimmer {
constructor(
private entity: { name: string; energy: number },
private energyCost: number = 5
) {}
swim(): void {
this.entity.energy -= this.energyCost;
console.log(`${this.entity.name} is swimming. Energy: ${this.entity.energy}`);
}
}
// Compose animals from behaviors
class Duck implements Eater, Sleeper, Flyer, Swimmer, Speaker {
public name: string;
public energy: number = 100;
private eater: Eater;
private sleeper: Sleeper;
private flyer: Flyer;
private swimmer: Swimmer;
constructor(name: string) {
this.name = name;
this.eater = new StandardEater(this);
this.sleeper = new StandardSleeper(this);
this.flyer = new WingedFlyer(this);
this.swimmer = new AquaticSwimmer(this);
}
eat(amount: number): void {
this.eater.eat(amount);
}
sleep(hours: number): void {
this.sleeper.sleep(hours);
}
fly(): void {
this.flyer.fly();
}
swim(): void {
this.swimmer.swim();
}
speak(): void {
console.log(`${this.name} says quack!`);
}
}
// Penguin: swims but doesn't fly - no problem!
class Penguin implements Eater, Sleeper, Swimmer, Speaker {
public name: string;
public energy: number = 100;
private eater: Eater;
private sleeper: Sleeper;
private swimmer: Swimmer;
constructor(name: string) {
this.name = name;
this.eater = new StandardEater(this);
this.sleeper = new StandardSleeper(this);
this.swimmer = new AquaticSwimmer(this);
}
eat(amount: number): void {
this.eater.eat(amount);
}
sleep(hours: number): void {
this.sleeper.sleep(hours);
}
swim(): void {
this.swimmer.swim();
}
speak(): void {
console.log(`${this.name} says squawk!`);
}
}
// Flying fish: swims and flies - easy!
class FlyingFish implements Swimmer, Flyer {
public name: string;
public energy: number = 100;
private swimmer: Swimmer;
private flyer: Flyer;
constructor(name: string) {
this.name = name;
this.swimmer = new AquaticSwimmer(this);
this.flyer = new WingedFlyer(this, 30); // Different energy cost
}
swim(): void {
this.swimmer.swim();
}
fly(): void {
this.flyer.fly();
}
}
// Robot bird: flies but doesn't eat or sleep
class RobotBird implements Flyer, Speaker {
public name: string;
public energy: number = 100;
private flyer: Flyer;
constructor(name: string) {
this.name = name;
this.flyer = new WingedFlyer(this, 10); // Efficient robot
}
fly(): void {
this.flyer.fly();
}
speak(): void {
console.log(`${this.name} says BEEP BOOP!`);
}
recharge(): void {
this.energy = 100;
console.log(`${this.name} recharged to full energy.`);
}
}
// Functions work with any entity that has the required behavior
function makeEntityFly(flyer: Flyer): void {
flyer.fly();
}
function feedEntity(eater: Eater, amount: number): void {
eater.eat(amount);
}
// Works with duck, flying fish, or robot bird
makeEntityFly(new Duck('Donald'));
makeEntityFly(new FlyingFish('Nemo'));
makeEntityFly(new RobotBird('R2D2'));
// Works with duck or penguin, but not robot bird (correctly!)
feedEntity(new Duck('Donald'), 50);
feedEntity(new Penguin('Pingu'), 50);
// feedEntity(new RobotBird('R2D2'), 50); // Type error - RobotBird isn't an Eater
```
## Why
1. **Flexibility**: Compose any combination of behaviors. No artificial hierarchy constraints.
2. **Avoids Diamond Problem**: No multiple inheritance issues. Just implement multiple interfaces.
3. **LSP Compliance**: No need to override methods to throw errors. Types only have methods they actually support.
4. **Reusability**: Behaviors can be reused across unrelated types.
5. **Testability**: Test behaviors in isolation. Mock specific behaviors easily.
6. **Runtime Flexibility**: Can change behaviors at runtime by swapping implementations.
7. **Stable Dependencies**: Behavior implementations are stable. Adding new composed types doesn't affect existing code.

View File

@@ -0,0 +1,316 @@
---
id: core-dry-extraction
title: DRY - Code Extraction
category: core-principles
priority: critical
tags: [DRY, refactoring, extraction, code-reuse]
related: [core-dry, core-dry-single-source, solid-srp-function]
---
# DRY - Code Extraction
Don't Repeat Yourself. When you find duplicated code, extract it into a reusable function, method, or module. Every piece of knowledge should have a single, unambiguous representation.
## Bad Example
```typescript
// Anti-pattern: Same validation logic repeated in multiple places
class UserController {
async createUser(req: Request, res: Response): Promise<void> {
const { email, password, name } = req.body;
// Email validation - duplicated
if (!email) {
res.status(400).json({ error: 'Email is required' });
return;
}
if (!email.includes('@') || !email.includes('.')) {
res.status(400).json({ error: 'Invalid email format' });
return;
}
if (email.length > 255) {
res.status(400).json({ error: 'Email too long' });
return;
}
// Password validation - duplicated
if (!password) {
res.status(400).json({ error: 'Password is required' });
return;
}
if (password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' });
return;
}
if (!/[A-Z]/.test(password)) {
res.status(400).json({ error: 'Password must contain uppercase letter' });
return;
}
if (!/[0-9]/.test(password)) {
res.status(400).json({ error: 'Password must contain a number' });
return;
}
// Create user...
}
async updateUser(req: Request, res: Response): Promise<void> {
const { email, password } = req.body;
// Same email validation repeated
if (email) {
if (!email.includes('@') || !email.includes('.')) {
res.status(400).json({ error: 'Invalid email format' });
return;
}
if (email.length > 255) {
res.status(400).json({ error: 'Email too long' });
return;
}
}
// Same password validation repeated
if (password) {
if (password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' });
return;
}
if (!/[A-Z]/.test(password)) {
res.status(400).json({ error: 'Password must contain uppercase letter' });
return;
}
if (!/[0-9]/.test(password)) {
res.status(400).json({ error: 'Password must contain a number' });
return;
}
}
// Update user...
}
async resetPassword(req: Request, res: Response): Promise<void> {
const { email, newPassword } = req.body;
// Email validation repeated again
if (!email) {
res.status(400).json({ error: 'Email is required' });
return;
}
if (!email.includes('@') || !email.includes('.')) {
res.status(400).json({ error: 'Invalid email format' });
return;
}
// Password validation repeated again
if (!newPassword) {
res.status(400).json({ error: 'New password is required' });
return;
}
if (newPassword.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' });
return;
}
if (!/[A-Z]/.test(newPassword)) {
res.status(400).json({ error: 'Password must contain uppercase letter' });
return;
}
if (!/[0-9]/.test(newPassword)) {
res.status(400).json({ error: 'Password must contain a number' });
return;
}
// Reset password...
}
}
```
## Good Example
```typescript
// Correct approach: Extract reusable validation functions
// Validation result type
interface ValidationResult {
isValid: boolean;
errors: string[];
}
// Reusable validation functions
class Validators {
static email(email: string | undefined, options: { required?: boolean } = {}): ValidationResult {
const errors: string[] = [];
if (!email) {
if (options.required) {
errors.push('Email is required');
}
return { isValid: !options.required, errors };
}
if (!email.includes('@') || !email.includes('.')) {
errors.push('Invalid email format');
}
if (email.length > 255) {
errors.push('Email must be 255 characters or less');
}
return { isValid: errors.length === 0, errors };
}
static password(password: string | undefined, options: { required?: boolean } = {}): ValidationResult {
const errors: string[] = [];
if (!password) {
if (options.required) {
errors.push('Password is required');
}
return { isValid: !options.required, errors };
}
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
return { isValid: errors.length === 0, errors };
}
static combine(...results: ValidationResult[]): ValidationResult {
const errors = results.flatMap(r => r.errors);
return { isValid: errors.length === 0, errors };
}
}
// Reusable error response helper
function validationError(res: Response, errors: string[]): void {
res.status(400).json({ errors });
}
// Clean controller using extracted validations
class UserController {
async createUser(req: Request, res: Response): Promise<void> {
const { email, password, name } = req.body;
const validation = Validators.combine(
Validators.email(email, { required: true }),
Validators.password(password, { required: true })
);
if (!validation.isValid) {
return validationError(res, validation.errors);
}
// Create user...
}
async updateUser(req: Request, res: Response): Promise<void> {
const { email, password } = req.body;
const validation = Validators.combine(
Validators.email(email),
Validators.password(password)
);
if (!validation.isValid) {
return validationError(res, validation.errors);
}
// Update user...
}
async resetPassword(req: Request, res: Response): Promise<void> {
const { email, newPassword } = req.body;
const validation = Validators.combine(
Validators.email(email, { required: true }),
Validators.password(newPassword, { required: true })
);
if (!validation.isValid) {
return validationError(res, validation.errors);
}
// Reset password...
}
}
// Validators can be reused across the application
class AdminController {
async inviteUser(req: Request, res: Response): Promise<void> {
const { email } = req.body;
const validation = Validators.email(email, { required: true });
if (!validation.isValid) {
return validationError(res, validation.errors);
}
// Send invite...
}
}
// Easy to test in isolation
describe('Validators', () => {
describe('email', () => {
it('should reject invalid email format', () => {
const result = Validators.email('invalid');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Invalid email format');
});
it('should accept valid email', () => {
const result = Validators.email('user@example.com');
expect(result.isValid).toBe(true);
});
it('should require email when required option is set', () => {
const result = Validators.email(undefined, { required: true });
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Email is required');
});
});
describe('password', () => {
it('should reject short passwords', () => {
const result = Validators.password('short');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
it('should require uppercase letter', () => {
const result = Validators.password('lowercase123');
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one uppercase letter');
});
});
});
```
## Why
1. **Single Source of Truth**: Password rules are defined once. Change them in one place, and all usages are updated.
2. **Consistency**: All email validations behave the same way. No risk of inconsistent error messages or rules.
3. **Easier Testing**: Test the validation logic once, thoroughly. No need to test the same logic in every controller.
4. **Bug Fixes Propagate**: Fix a bug in `Validators.email()`, and it's fixed everywhere.
5. **Reduced Code Size**: Less code means less to read, less to maintain, and fewer places for bugs.
6. **Better Abstraction**: Controllers focus on HTTP concerns, validators focus on validation.
7. **Reusability**: Same validators work in controllers, services, CLI tools, or anywhere else.

View File

@@ -0,0 +1,282 @@
---
id: core-dry-single-source
title: DRY - Single Source of Truth
category: core-principles
priority: critical
tags: [DRY, single-source-of-truth, constants, configuration]
related: [core-dry, core-dry-extraction, core-encapsulation]
---
# DRY - Single Source of Truth
Every piece of knowledge or configuration should exist in exactly one place. When data or logic needs to be referenced from multiple locations, use a single authoritative source.
## Bad Example
```typescript
// Anti-pattern: Same values defined in multiple places
// In constants file
const API_BASE_URL = 'https://api.example.com/v1';
// In another file - duplicate!
const baseUrl = 'https://api.example.com/v1';
// In config.ts - another duplicate!
export const config = {
apiUrl: 'https://api.example.com/v1'
};
// In API client - yet another!
class ApiClient {
private baseUrl = 'https://api.example.com/v1'; // Duplicated!
}
// Status codes defined in multiple places
class OrderService {
async getOrder(id: string): Promise<Order> {
const order = await this.repository.findById(id);
if (order.status === 'pending') { // Magic string
// ...
}
if (order.status === 'completed') { // Magic string
// ...
}
}
}
class OrderController {
async listPendingOrders(): Promise<Order[]> {
return this.repository.findByStatus('pending'); // Same magic string
}
}
// In frontend code
const isPending = order.status === 'pending'; // And again
// Database seeds with duplicated data
const seedRoles = [
{ id: 1, name: 'admin', permissions: ['read', 'write', 'delete', 'admin'] },
{ id: 2, name: 'editor', permissions: ['read', 'write'] },
{ id: 3, name: 'viewer', permissions: ['read'] }
];
// In authorization middleware - duplicated permission logic
function checkPermission(user: User, action: string): boolean {
if (user.role === 'admin') {
return true; // Admin can do anything - duplicated knowledge
}
if (user.role === 'editor' && ['read', 'write'].includes(action)) {
return true; // Editor permissions - duplicated
}
if (user.role === 'viewer' && action === 'read') {
return true; // Viewer permissions - duplicated
}
return false;
}
```
## Good Example
```typescript
// Correct approach: Single source of truth for all shared knowledge
// Configuration - one authoritative source
// config/index.ts
export const Config = {
api: {
baseUrl: process.env.API_BASE_URL || 'https://api.example.com/v1',
timeout: Number(process.env.API_TIMEOUT) || 30000,
retries: Number(process.env.API_RETRIES) || 3
},
database: {
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL_SIZE) || 10
}
} as const;
// All code references the single config
class ApiClient {
constructor(private baseUrl: string = Config.api.baseUrl) {}
}
// Enums for finite sets of values - single source of truth
// domain/order/status.ts
export const OrderStatus = {
PENDING: 'pending',
PROCESSING: 'processing',
SHIPPED: 'shipped',
DELIVERED: 'delivered',
CANCELLED: 'cancelled'
} as const;
export type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];
// All code uses the enum
class OrderService {
async getOrder(id: string): Promise<Order> {
const order = await this.repository.findById(id);
if (order.status === OrderStatus.PENDING) {
// Single source of truth
}
}
}
class OrderController {
async listPendingOrders(): Promise<Order[]> {
return this.repository.findByStatus(OrderStatus.PENDING); // Same source
}
}
// Roles and permissions - single authoritative definition
// domain/auth/roles.ts
export const Permission = {
READ: 'read',
WRITE: 'write',
DELETE: 'delete',
ADMIN: 'admin'
} as const;
export type Permission = typeof Permission[keyof typeof Permission];
export const RoleDefinitions = {
admin: {
name: 'Administrator',
permissions: [Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN]
},
editor: {
name: 'Editor',
permissions: [Permission.READ, Permission.WRITE]
},
viewer: {
name: 'Viewer',
permissions: [Permission.READ]
}
} as const;
export type RoleName = keyof typeof RoleDefinitions;
// Permission checking uses the definitions
export function hasPermission(role: RoleName, permission: Permission): boolean {
const roleDef = RoleDefinitions[role];
return roleDef.permissions.includes(permission);
}
// Database seeds generated from the single source
export function generateRoleSeeds(): RoleSeed[] {
return Object.entries(RoleDefinitions).map(([key, def], index) => ({
id: index + 1,
name: key,
displayName: def.name,
permissions: [...def.permissions]
}));
}
// Validation rules - single source
// domain/user/validation.ts
export const UserValidationRules = {
email: {
maxLength: 255,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
password: {
minLength: 8,
maxLength: 128,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecial: false
},
username: {
minLength: 3,
maxLength: 30,
pattern: /^[a-zA-Z0-9_]+$/
}
} as const;
// Validators use the rules
export function validateEmail(email: string): ValidationResult {
const rules = UserValidationRules.email;
if (email.length > rules.maxLength) {
return { valid: false, error: `Email must be ${rules.maxLength} characters or less` };
}
if (!rules.pattern.test(email)) {
return { valid: false, error: 'Invalid email format' };
}
return { valid: true };
}
// Database schema uses the same rules
// migrations/001_create_users.ts
export const createUsersTable = `
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(${UserValidationRules.email.maxLength}) NOT NULL UNIQUE,
username VARCHAR(${UserValidationRules.username.maxLength}) NOT NULL UNIQUE,
-- ...
)
`;
// Frontend validation uses the same rules (shared module)
// shared/validation.ts (used by both frontend and backend)
export function getPasswordRequirements(): string[] {
const rules = UserValidationRules.password;
const requirements: string[] = [];
requirements.push(`At least ${rules.minLength} characters`);
if (rules.requireUppercase) requirements.push('At least one uppercase letter');
if (rules.requireLowercase) requirements.push('At least one lowercase letter');
if (rules.requireNumber) requirements.push('At least one number');
if (rules.requireSpecial) requirements.push('At least one special character');
return requirements;
}
// Error messages - single source
// errors/messages.ts
export const ErrorMessages = {
user: {
notFound: 'User not found',
alreadyExists: 'A user with this email already exists',
invalidCredentials: 'Invalid email or password',
accountLocked: 'Account is locked. Please contact support.'
},
order: {
notFound: 'Order not found',
alreadyCancelled: 'Order has already been cancelled',
cannotCancel: 'Order cannot be cancelled in its current state'
},
auth: {
tokenExpired: 'Your session has expired. Please log in again.',
unauthorized: 'You do not have permission to perform this action'
}
} as const;
// All code uses the same error messages
class UserService {
async findById(id: string): Promise<User> {
const user = await this.repository.findById(id);
if (!user) {
throw new NotFoundError(ErrorMessages.user.notFound);
}
return user;
}
}
```
## Why
1. **Consistency**: The same value is always the same everywhere. No "this worked yesterday" bugs.
2. **Easy Updates**: Change a validation rule, config value, or status code in one place.
3. **Prevents Drift**: Without a single source, values diverge over time as different developers make changes.
4. **Documentation**: The source file serves as documentation for what values are valid.
5. **Type Safety**: TypeScript can enforce that only valid values are used.
6. **Searchability**: Easy to find all usages by searching for the constant name.
7. **Refactoring**: Rename a status? Change it in one place and let the compiler find all usages.

View File

@@ -0,0 +1,320 @@
---
id: core-dry
title: Don't Repeat Yourself (DRY)
category: core-principles
priority: critical
tags: [DRY, duplication, single-source-of-truth, maintainability]
related: [core-dry-extraction, core-dry-single-source, solid-srp-class]
---
# Don't Repeat Yourself (DRY)
## Why It Matters
"Don't Repeat Yourself" means every piece of knowledge should have a single, authoritative representation. Duplication leads to inconsistencies, increases maintenance burden, and makes bugs harder to fix. When you change one copy but forget others, bugs creep in.
## Incorrect
```typescript
// ❌ Duplicated validation logic
class UserController {
createUser(data) {
if (!data.email || !data.email.includes('@')) {
throw new Error('Invalid email');
}
if (!data.password || data.password.length < 8) {
throw new Error('Password too short');
}
// create user...
}
updateUser(id, data) {
if (!data.email || !data.email.includes('@')) { // Duplicated
throw new Error('Invalid email');
}
if (!data.password || data.password.length < 8) { // Duplicated
throw new Error('Password too short');
}
// update user...
}
}
// ❌ Duplicated business rules
function calculateOrderTotal(items) {
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
}
if (total > 100) {
total = total * 0.9; // 10% discount over $100
}
return total;
}
function calculateCartTotal(cartItems) {
let total = 0;
for (const item of cartItems) {
total += item.price * item.quantity; // Duplicated
}
if (total > 100) {
total = total * 0.9; // Duplicated discount logic
}
return total;
}
// ❌ Duplicated constants
// file: checkout.ts
const TAX_RATE = 0.08;
const FREE_SHIPPING_THRESHOLD = 50;
// file: cart.ts
const TAX_RATE = 0.08; // Duplicated
const FREE_SHIPPING_THRESHOLD = 50; // Duplicated
```
## Correct
### Extract Shared Validation
```typescript
// ✅ Single validation module
// validators/user.ts
export class UserValidator {
static validateEmail(email: string): void {
if (!email || !email.includes('@')) {
throw new ValidationError('Invalid email address');
}
}
static validatePassword(password: string): void {
if (!password || password.length < 8) {
throw new ValidationError('Password must be at least 8 characters');
}
}
static validate(data: UserData): void {
this.validateEmail(data.email);
this.validatePassword(data.password);
}
}
// controller.ts
class UserController {
createUser(data) {
UserValidator.validate(data);
// create user...
}
updateUser(id, data) {
UserValidator.validate(data);
// update user...
}
}
```
### Extract Shared Business Logic
```typescript
// ✅ Single source of truth for pricing
// services/pricing.ts
export class PricingService {
private static readonly BULK_DISCOUNT_THRESHOLD = 100;
private static readonly BULK_DISCOUNT_RATE = 0.1;
static calculateSubtotal(items: LineItem[]): number {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
}
static applyDiscount(subtotal: number): number {
if (subtotal > this.BULK_DISCOUNT_THRESHOLD) {
return subtotal * (1 - this.BULK_DISCOUNT_RATE);
}
return subtotal;
}
static calculateTotal(items: LineItem[]): number {
const subtotal = this.calculateSubtotal(items);
return this.applyDiscount(subtotal);
}
}
// Both order and cart use the same logic
const orderTotal = PricingService.calculateTotal(order.items);
const cartTotal = PricingService.calculateTotal(cart.items);
```
### Centralize Constants
```typescript
// ✅ Single constants file
// constants/pricing.ts
export const PRICING = {
TAX_RATE: 0.08,
FREE_SHIPPING_THRESHOLD: 50,
BULK_DISCOUNT_THRESHOLD: 100,
BULK_DISCOUNT_RATE: 0.1,
} as const;
// Used everywhere
import { PRICING } from '@/constants/pricing';
const tax = subtotal * PRICING.TAX_RATE;
const freeShipping = total >= PRICING.FREE_SHIPPING_THRESHOLD;
```
### Extract Shared Components
```tsx
// ❌ Duplicated UI patterns
function UserCard({ user }) {
return (
<div className="p-4 rounded-lg shadow bg-white">
<img src={user.avatar} className="w-12 h-12 rounded-full" />
<h3 className="font-bold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
</div>
);
}
function TeamMemberCard({ member }) {
return (
<div className="p-4 rounded-lg shadow bg-white"> {/* Same styles */}
<img src={member.avatar} className="w-12 h-12 rounded-full" />
<h3 className="font-bold">{member.name}</h3>
<p className="text-gray-600">{member.role}</p>
</div>
);
}
// ✅ Reusable component
function Card({ children, className }) {
return (
<div className={cn("p-4 rounded-lg shadow bg-white", className)}>
{children}
</div>
);
}
function Avatar({ src, alt }) {
return <img src={src} alt={alt} className="w-12 h-12 rounded-full" />;
}
function UserCard({ user }) {
return (
<Card>
<Avatar src={user.avatar} alt={user.name} />
<h3 className="font-bold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
</Card>
);
}
function TeamMemberCard({ member }) {
return (
<Card>
<Avatar src={member.avatar} alt={member.name} />
<h3 className="font-bold">{member.name}</h3>
<p className="text-gray-600">{member.role}</p>
</Card>
);
}
```
### Extract Shared Hooks
```typescript
// ❌ Duplicated fetch logic
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...
}
function ProductList() {
const [products, setProducts] = useState(null);
const [loading, setLoading] = useState(true); // Duplicated
const [error, setError] = useState(null); // Duplicated
useEffect(() => {
fetch('/api/products') // Same pattern
.then(res => res.json())
.then(setProducts)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...
}
// ✅ Custom hook (or use React Query)
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
function UserProfile() {
const { data: user, loading, error } = useFetch<User>('/api/user');
// ...
}
function ProductList() {
const { data: products, loading, error } = useFetch<Product[]>('/api/products');
// ...
}
```
## When NOT to DRY
```typescript
// ⚠️ Don't extract coincidentally similar code
// These might look similar but serve different purposes
function validateUserAge(age: number) {
return age >= 18; // Legal adult age
}
function validateMinimumOrderQuantity(quantity: number) {
return quantity >= 18; // Business rule: minimum order
}
// These should remain separate even though both check >= 18
// Their reasons for change are different
```
## Rule of Three
```
Wait until you see duplication three times before extracting.
Two occurrences might be coincidental. Three indicates a pattern.
```
## Benefits
- Single source of truth
- Fix bugs in one place
- Consistent behavior across codebase
- Easier refactoring
- Reduced code size
- Lower maintenance cost

View File

@@ -0,0 +1,335 @@
---
id: core-encapsulation
title: Encapsulation
category: core-principles
priority: critical
tags: [encapsulation, information-hiding, data-protection]
related: [solid-srp-class, core-law-demeter, solid-isp-interfaces]
---
# Encapsulation
Hide internal implementation details and expose only what's necessary through a well-defined interface. Protect data integrity by controlling access to internal state.
## Bad Example
```typescript
// Anti-pattern: Exposed internals, no encapsulation
class BankAccount {
// Public fields - anyone can modify directly
public accountNumber: string;
public balance: number;
public transactions: Transaction[];
public overdraftLimit: number;
public isLocked: boolean;
constructor(accountNumber: string, initialBalance: number) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
this.transactions = [];
this.overdraftLimit = 0;
this.isLocked = false;
}
}
// External code can violate business rules
const account = new BankAccount('12345', 1000);
// Direct modification bypasses validation
account.balance = -999999; // Negative balance without check!
account.balance = account.balance + 1000; // No transaction record!
// Can manipulate transaction history
account.transactions.push({
id: 'fake',
amount: 1000000,
type: 'deposit'
}); // Fraudulent transaction!
// Can unlock locked accounts
account.isLocked = false; // Bypasses security!
// Can change overdraft without authorization
account.overdraftLimit = 100000; // Unauthorized overdraft!
// Other classes depend on internal structure
class AccountReport {
generate(account: BankAccount): Report {
// Directly accesses internal array
const deposits = account.transactions.filter(t => t.type === 'deposit');
const withdrawals = account.transactions.filter(t => t.type === 'withdrawal');
// Depends on internal structure of Transaction
const totalDeposits = deposits.reduce((sum, t) => sum + t.amount, 0);
return {
balance: account.balance,
totalDeposits,
transactionCount: account.transactions.length
};
}
}
// Problems:
// 1. Anyone can modify balance without recording transaction
// 2. Business rules can be bypassed
// 3. No audit trail for changes
// 4. Internal structure changes break external code
// 5. No way to add validation later without breaking changes
```
## Good Example
```typescript
// Correct approach: Proper encapsulation
class BankAccount {
private readonly _accountNumber: string;
private _balance: number;
private readonly _transactions: Transaction[] = [];
private _overdraftLimit: number = 0;
private _isLocked: boolean = false;
private _lockReason: string | null = null;
constructor(accountNumber: string, initialBalance: number) {
if (!accountNumber || accountNumber.length < 5) {
throw new Error('Invalid account number');
}
if (initialBalance < 0) {
throw new Error('Initial balance cannot be negative');
}
this._accountNumber = accountNumber;
this._balance = initialBalance;
this._transactions.push(
Transaction.createInitial(initialBalance)
);
}
// Read-only access to account number
get accountNumber(): string {
return this._accountNumber;
}
// Read-only access to balance
get balance(): number {
return this._balance;
}
// Read-only access to lock status
get isLocked(): boolean {
return this._isLocked;
}
// Controlled deposit with validation and audit trail
deposit(amount: number, description: string = 'Deposit'): Transaction {
this.ensureNotLocked();
if (amount <= 0) {
throw new InvalidAmountError('Deposit amount must be positive');
}
this._balance += amount;
const transaction = Transaction.createDeposit(amount, description, this._balance);
this._transactions.push(transaction);
return transaction;
}
// Controlled withdrawal with business rules
withdraw(amount: number, description: string = 'Withdrawal'): Transaction {
this.ensureNotLocked();
if (amount <= 0) {
throw new InvalidAmountError('Withdrawal amount must be positive');
}
const availableBalance = this._balance + this._overdraftLimit;
if (amount > availableBalance) {
throw new InsufficientFundsError(amount, availableBalance);
}
this._balance -= amount;
const transaction = Transaction.createWithdrawal(amount, description, this._balance);
this._transactions.push(transaction);
return transaction;
}
// Transfer with proper validation
transferTo(recipient: BankAccount, amount: number): TransferResult {
this.ensureNotLocked();
recipient.ensureNotLocked();
if (amount <= 0) {
throw new InvalidAmountError('Transfer amount must be positive');
}
const withdrawalTx = this.withdraw(amount, `Transfer to ${recipient.accountNumber}`);
const depositTx = recipient.deposit(amount, `Transfer from ${this._accountNumber}`);
return { withdrawalTx, depositTx };
}
// Controlled overdraft limit modification
setOverdraftLimit(limit: number, authorizedBy: string): void {
if (limit < 0) {
throw new Error('Overdraft limit cannot be negative');
}
if (limit > 10000) {
throw new Error('Overdraft limit exceeds maximum allowed');
}
this._overdraftLimit = limit;
// Audit trail for limit changes
this._transactions.push(
Transaction.createAdministrative(
`Overdraft limit set to ${limit} by ${authorizedBy}`
)
);
}
// Security control with audit
lock(reason: string): void {
this._isLocked = true;
this._lockReason = reason;
this._transactions.push(
Transaction.createAdministrative(`Account locked: ${reason}`)
);
}
unlock(authorizedBy: string): void {
this._isLocked = false;
this._lockReason = null;
this._transactions.push(
Transaction.createAdministrative(`Account unlocked by ${authorizedBy}`)
);
}
// Return copy of transactions, not the internal array
getTransactionHistory(): ReadonlyArray<Transaction> {
return [...this._transactions];
}
// Provide summary without exposing internals
getSummary(): AccountSummary {
const deposits = this._transactions.filter(t => t.type === 'deposit');
const withdrawals = this._transactions.filter(t => t.type === 'withdrawal');
return {
accountNumber: this._accountNumber,
balance: this._balance,
isLocked: this._isLocked,
totalDeposits: deposits.reduce((sum, t) => sum + t.amount, 0),
totalWithdrawals: withdrawals.reduce((sum, t) => sum + t.amount, 0),
transactionCount: this._transactions.length
};
}
// Private helper method
private ensureNotLocked(): void {
if (this._isLocked) {
throw new AccountLockedError(this._lockReason || 'Account is locked');
}
}
}
// Transaction is also encapsulated
class Transaction {
private constructor(
public readonly id: string,
public readonly type: TransactionType,
public readonly amount: number,
public readonly description: string,
public readonly balanceAfter: number,
public readonly timestamp: Date
) {}
static createDeposit(amount: number, description: string, balanceAfter: number): Transaction {
return new Transaction(
generateId(),
'deposit',
amount,
description,
balanceAfter,
new Date()
);
}
static createWithdrawal(amount: number, description: string, balanceAfter: number): Transaction {
return new Transaction(
generateId(),
'withdrawal',
amount,
description,
balanceAfter,
new Date()
);
}
static createInitial(balance: number): Transaction {
return new Transaction(
generateId(),
'initial',
balance,
'Account opened',
balance,
new Date()
);
}
static createAdministrative(description: string): Transaction {
return new Transaction(
generateId(),
'administrative',
0,
description,
0,
new Date()
);
}
}
// Usage - business rules are enforced
const account = new BankAccount('12345', 1000);
// Proper deposit with audit trail
account.deposit(500, 'Paycheck');
// Withdrawal validates funds
try {
account.withdraw(2000); // Will throw InsufficientFundsError
} catch (error) {
console.log('Cannot withdraw more than available');
}
// Cannot manipulate balance directly
// account.balance = 999999; // Error: Property 'balance' is read-only
// Cannot manipulate transactions
// account.getTransactionHistory().push(fakeTx); // Original array unaffected
// Account summary provides what reports need
const summary = account.getSummary();
console.log(`Balance: ${summary.balance}, Transactions: ${summary.transactionCount}`);
```
## Why
1. **Data Integrity**: Balance can only change through proper deposit/withdraw methods that maintain consistency.
2. **Business Rules**: All rules (positive amounts, sufficient funds, locked accounts) are enforced in one place.
3. **Audit Trail**: Every change is recorded. Cannot modify history without proper methods.
4. **Flexibility**: Internal representation can change without affecting clients. Switch from array to database later.
5. **Security**: Cannot bypass validation or manipulate internal state directly.
6. **Testing**: Can verify all edge cases through the public interface. Internal state is controlled.
7. **Documentation**: The public interface documents what operations are allowed and how to use them.

View File

@@ -0,0 +1,325 @@
---
id: core-fail-fast
title: Fail Fast Principle
category: core-principles
priority: critical
tags: [fail-fast, error-handling, validation]
related: [solid-lsp-preconditions, core-encapsulation]
---
# Fail Fast Principle
Detect and report errors as early as possible. Validate inputs at system boundaries, check preconditions at the start of functions, and throw exceptions immediately when something is wrong.
## Bad Example
```typescript
// Anti-pattern: Delayed error detection
class OrderProcessor {
async processOrder(order: any): Promise<ProcessResult> {
// No validation - problems will surface later
// Proceeds even with potentially invalid data
const customer = await this.customerRepo.findById(order.customerId);
// Customer might be null, but we keep going
const items = order.items;
// Calculates total even if items might be undefined or empty
let total = 0;
if (items) {
for (const item of items) {
// item.productId might not exist
const product = await this.productRepo.findById(item.productId);
// product might be null
if (product) {
total += product.price * (item.quantity || 1);
}
}
}
// Attempts payment even if customer is null
let paymentResult;
if (customer && customer.paymentMethod) {
paymentResult = await this.paymentService.charge(
customer.paymentMethod,
total
);
}
// Creates order record even if payment failed
const orderRecord = await this.orderRepo.create({
customerId: order.customerId,
total,
status: paymentResult?.success ? 'paid' : 'pending'
});
// Sends email even if we don't have a valid email
if (customer?.email) {
await this.emailService.send(customer.email, 'Order confirmation');
}
// Returns "success" even though many things might have gone wrong
return { success: true, orderId: orderRecord.id };
}
}
// Problems:
// 1. Null customer leads to silent failures
// 2. Empty order goes through system doing nothing useful
// 3. Payment failure creates orphaned order records
// 4. No indication of what went wrong
// 5. Database in inconsistent state
```
## Good Example
```typescript
// Correct approach: Fail fast with immediate validation
// Custom error types for clear failure reasons
class ValidationError extends Error {
constructor(
message: string,
public readonly field: string,
public readonly value: unknown
) {
super(message);
this.name = 'ValidationError';
}
}
class NotFoundError extends Error {
constructor(
public readonly entity: string,
public readonly id: string
) {
super(`${entity} not found: ${id}`);
this.name = 'NotFoundError';
}
}
class PaymentError extends Error {
constructor(
message: string,
public readonly code: string
) {
super(message);
this.name = 'PaymentError';
}
}
// Input validation schema
interface CreateOrderInput {
customerId: string;
items: OrderItemInput[];
}
interface OrderItemInput {
productId: string;
quantity: number;
}
class OrderProcessor {
async processOrder(input: unknown): Promise<ProcessResult> {
// STEP 1: Validate input immediately
const validatedInput = this.validateInput(input);
// STEP 2: Verify customer exists before proceeding
const customer = await this.loadCustomer(validatedInput.customerId);
// STEP 3: Verify all products exist before any processing
const products = await this.loadProducts(validatedInput.items);
// STEP 4: Verify payment method before creating order
this.verifyPaymentMethod(customer);
// STEP 5: Calculate total (now safe - all data validated)
const total = this.calculateTotal(validatedInput.items, products);
// STEP 6: Verify sufficient inventory before payment
await this.verifyInventory(validatedInput.items, products);
// STEP 7: Process payment before creating order
const payment = await this.processPayment(customer, total);
// STEP 8: Create order only after successful payment
const order = await this.createOrder(validatedInput, customer, total, payment);
// STEP 9: Send confirmation (non-critical, can fail gracefully)
await this.sendConfirmation(customer, order);
return { success: true, orderId: order.id };
}
private validateInput(input: unknown): CreateOrderInput {
if (!input || typeof input !== 'object') {
throw new ValidationError('Input must be an object', 'input', input);
}
const obj = input as Record<string, unknown>;
if (!obj.customerId || typeof obj.customerId !== 'string') {
throw new ValidationError('customerId is required and must be a string', 'customerId', obj.customerId);
}
if (!Array.isArray(obj.items) || obj.items.length === 0) {
throw new ValidationError('items must be a non-empty array', 'items', obj.items);
}
const validatedItems: OrderItemInput[] = [];
for (let i = 0; i < obj.items.length; i++) {
const item = obj.items[i];
if (!item || typeof item !== 'object') {
throw new ValidationError(`Item at index ${i} must be an object`, `items[${i}]`, item);
}
const itemObj = item as Record<string, unknown>;
if (!itemObj.productId || typeof itemObj.productId !== 'string') {
throw new ValidationError(
`productId is required at index ${i}`,
`items[${i}].productId`,
itemObj.productId
);
}
if (typeof itemObj.quantity !== 'number' || itemObj.quantity < 1) {
throw new ValidationError(
`quantity must be a positive number at index ${i}`,
`items[${i}].quantity`,
itemObj.quantity
);
}
validatedItems.push({
productId: itemObj.productId,
quantity: itemObj.quantity
});
}
return {
customerId: obj.customerId,
items: validatedItems
};
}
private async loadCustomer(customerId: string): Promise<Customer> {
const customer = await this.customerRepo.findById(customerId);
if (!customer) {
throw new NotFoundError('Customer', customerId);
}
if (!customer.isActive) {
throw new ValidationError('Customer account is inactive', 'customerId', customerId);
}
return customer;
}
private async loadProducts(items: OrderItemInput[]): Promise<Map<string, Product>> {
const productIds = items.map(item => item.productId);
const products = await this.productRepo.findByIds(productIds);
const productMap = new Map<string, Product>();
for (const product of products) {
productMap.set(product.id, product);
}
// Verify all products were found
for (const item of items) {
if (!productMap.has(item.productId)) {
throw new NotFoundError('Product', item.productId);
}
}
return productMap;
}
private verifyPaymentMethod(customer: Customer): void {
if (!customer.paymentMethodId) {
throw new ValidationError(
'Customer has no payment method configured',
'paymentMethod',
null
);
}
}
private calculateTotal(items: OrderItemInput[], products: Map<string, Product>): number {
return items.reduce((total, item) => {
const product = products.get(item.productId)!; // Safe - already validated
return total + product.price * item.quantity;
}, 0);
}
private async verifyInventory(
items: OrderItemInput[],
products: Map<string, Product>
): Promise<void> {
for (const item of items) {
const product = products.get(item.productId)!;
if (product.stock < item.quantity) {
throw new ValidationError(
`Insufficient stock for product ${product.name}. Available: ${product.stock}, Requested: ${item.quantity}`,
'quantity',
item.quantity
);
}
}
}
private async processPayment(customer: Customer, amount: number): Promise<Payment> {
try {
return await this.paymentService.charge(customer.paymentMethodId, amount);
} catch (error) {
throw new PaymentError(
`Payment failed: ${error.message}`,
error.code || 'UNKNOWN'
);
}
}
private async createOrder(
input: CreateOrderInput,
customer: Customer,
total: number,
payment: Payment
): Promise<Order> {
return this.orderRepo.create({
customerId: customer.id,
items: input.items,
total,
paymentId: payment.id,
status: 'paid'
});
}
private async sendConfirmation(customer: Customer, order: Order): Promise<void> {
try {
await this.emailService.sendOrderConfirmation(customer.email, order);
} catch (error) {
// Log but don't fail - email is non-critical
this.logger.error('Failed to send order confirmation', { orderId: order.id, error });
}
}
}
```
## Why
1. **Clear Error Messages**: When validation fails, you know exactly what's wrong and where.
2. **No Wasted Work**: Invalid requests fail immediately, not after expensive operations.
3. **Data Integrity**: The database never enters an inconsistent state because we validate before mutating.
4. **Debugging**: Stack traces point to the actual problem, not to a downstream symptom.
5. **Predictability**: Either the operation fully succeeds or it cleanly fails with a clear reason.
6. **Security**: Invalid inputs are rejected at the boundary, not passed through the system.
7. **Recovery**: Callers can handle specific error types appropriately (retry, report, fallback).

View File

@@ -0,0 +1,202 @@
---
id: core-kiss-readability
title: KISS - Readability
category: core-principles
priority: critical
tags: [KISS, readability, clear-code, maintainability]
related: [core-kiss-simplicity, solid-srp-function]
---
# KISS - Readability
Code is read far more often than it is written. Optimize for readability by using clear names, straightforward logic, and avoiding clever tricks that obscure intent.
## Bad Example
```typescript
// Anti-pattern: Clever but cryptic code
// One-liner that's hard to understand
const r = d.filter(x => x.s === 'a' && x.t > Date.now() - 864e5).reduce((a, x) => ({ ...a, [x.c]: (a[x.c] || 0) + x.v }), {});
// Nested ternaries
const status = x > 100 ? 'high' : x > 50 ? 'medium' : x > 20 ? 'low' : x > 0 ? 'minimal' : 'none';
// Bitwise operations for boolean logic
const isValid = !!(flags & 0x1) && !!(flags & 0x2) || !!(flags & 0x4);
// Regex that nobody can read
const isValidEmail = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i.test(email);
// Clever use of || and && for control flow
user && user.isActive && user.hasPermission('admin') && doAdminStuff() || showError();
// Abusing array methods
const result = [...Array(10)].map((_, i) => i * 2).filter(Boolean).reduce((a, b) => a + b, 0);
// Short variable names that save typing but cost understanding
function p(d, o) {
return d.map(i => ({ ...i, t: i.t * o.r, s: o.s ? i.s + o.s : i.s })).filter(i => i.t > o.m);
}
```
## Good Example
```typescript
// Correct approach: Clear, self-documenting code
// Break complex operations into named steps
function getActiveOrdersSummaryByCategory(orders: Order[]): Record<string, number> {
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const activeRecentOrders = orders.filter(order =>
order.status === 'active' && order.timestamp > oneDayAgo
);
const summaryByCategory: Record<string, number> = {};
for (const order of activeRecentOrders) {
const currentTotal = summaryByCategory[order.category] || 0;
summaryByCategory[order.category] = currentTotal + order.value;
}
return summaryByCategory;
}
// Use clear conditional logic
function getAlertLevel(value: number): AlertLevel {
if (value > 100) {
return 'high';
}
if (value > 50) {
return 'medium';
}
if (value > 20) {
return 'low';
}
if (value > 0) {
return 'minimal';
}
return 'none';
}
// Use named constants for flags
const UserFlags = {
IS_VERIFIED: 0x1,
IS_PREMIUM: 0x2,
IS_ADMIN: 0x4
} as const;
function hasUserFlag(flags: number, flag: number): boolean {
return (flags & flag) !== 0;
}
function isValidPremiumAdmin(flags: number): boolean {
const isVerified = hasUserFlag(flags, UserFlags.IS_VERIFIED);
const isPremium = hasUserFlag(flags, UserFlags.IS_PREMIUM);
const isAdmin = hasUserFlag(flags, UserFlags.IS_ADMIN);
return (isVerified && isPremium) || isAdmin;
}
// Use a simple email validation
function isValidEmail(email: string): boolean {
if (!email || email.length > 254) {
return false;
}
const atIndex = email.indexOf('@');
if (atIndex < 1) {
return false;
}
const domain = email.slice(atIndex + 1);
if (!domain || !domain.includes('.')) {
return false;
}
return true;
}
// Or use a well-tested library with clear intent
import { isEmail } from 'validator';
const isValidEmail = isEmail(email);
// Use explicit control flow
function handleUserAction(user: User | null): void {
if (!user) {
showError('User not found');
return;
}
if (!user.isActive) {
showError('User account is inactive');
return;
}
if (!user.hasPermission('admin')) {
showError('Admin permission required');
return;
}
doAdminStuff();
}
// Use descriptive variable and function names
function generateEvenNumbersSum(count: number): number {
const evenNumbers: number[] = [];
for (let i = 0; i < count; i++) {
evenNumbers.push(i * 2);
}
const sum = evenNumbers.reduce((total, num) => total + num, 0);
return sum;
}
// Use full, descriptive parameter names
interface PriceAdjustmentOptions {
rateMultiplier: number;
shippingSurcharge?: number;
minimumThreshold: number;
}
function adjustProductPrices(
products: Product[],
options: PriceAdjustmentOptions
): Product[] {
return products
.map(product => ({
...product,
totalPrice: product.basePrice * options.rateMultiplier,
shippingCost: options.shippingSurcharge
? product.shippingCost + options.shippingSurcharge
: product.shippingCost
}))
.filter(product => product.totalPrice > options.minimumThreshold);
}
// Usage is self-documenting
const adjustedProducts = adjustProductPrices(products, {
rateMultiplier: 1.1,
shippingSurcharge: 5,
minimumThreshold: 20
});
```
## Why
1. **Comprehension Speed**: Readable code is understood quickly. Clever code requires deciphering.
2. **Fewer Bugs**: When code clearly expresses intent, mistakes are obvious.
3. **Onboarding**: New team members can contribute faster with readable code.
4. **Code Reviews**: Reviewers can focus on logic, not translation.
5. **Future You**: Code you wrote 6 months ago might as well have been written by someone else.
6. **Maintenance Cost**: Most of a codebase's lifetime is spent in maintenance, not initial development.
7. **Collaboration**: Teams work better when everyone can understand everyone's code.

View File

@@ -0,0 +1,255 @@
---
id: core-kiss-simplicity
title: KISS - Simplicity
category: core-principles
priority: critical
tags: [KISS, simplicity, over-engineering, maintainability]
related: [core-kiss-readability, core-yagni-abstractions, solid-srp-function]
---
# KISS Principle - Simplicity
Keep It Simple, Stupid. Choose the simplest solution that solves the problem. Avoid unnecessary complexity, over-engineering, and clever code that's hard to understand.
## Bad Example
```typescript
// Anti-pattern: Over-engineered solution for a simple problem
// Simple task: Check if a user can access a resource
// Over-engineered approach with unnecessary abstractions
interface AccessControlStrategy {
evaluate(context: AccessContext): AccessDecision;
}
interface AccessContext {
subject: Subject;
resource: Resource;
action: Action;
environment: Environment;
}
interface Subject {
id: string;
attributes: Map<string, AttributeValue>;
}
interface Resource {
id: string;
type: string;
attributes: Map<string, AttributeValue>;
}
interface Action {
id: string;
attributes: Map<string, AttributeValue>;
}
interface Environment {
currentTime: Date;
ipAddress: string;
attributes: Map<string, AttributeValue>;
}
type AttributeValue = string | number | boolean | string[];
interface AccessDecision {
decision: 'permit' | 'deny' | 'indeterminate' | 'not_applicable';
obligations?: Obligation[];
advice?: Advice[];
}
interface Obligation {
id: string;
fulfillOn: 'permit' | 'deny';
attributes: Map<string, AttributeValue>;
}
interface Advice {
id: string;
appliesTo: 'permit' | 'deny';
attributes: Map<string, AttributeValue>;
}
class AttributeBasedAccessControl {
private strategies: AccessControlStrategy[] = [];
private combiningAlgorithm: CombiningAlgorithm;
constructor(combiningAlgorithm: CombiningAlgorithm) {
this.combiningAlgorithm = combiningAlgorithm;
}
addStrategy(strategy: AccessControlStrategy): void {
this.strategies.push(strategy);
}
evaluate(context: AccessContext): AccessDecision {
const decisions = this.strategies.map(s => s.evaluate(context));
return this.combiningAlgorithm.combine(decisions);
}
}
// 500+ more lines of abstraction layers...
// Actually usage for a simple check:
const context: AccessContext = {
subject: {
id: user.id,
attributes: new Map([['role', user.role]])
},
resource: {
id: document.id,
type: 'document',
attributes: new Map([['ownerId', document.ownerId]])
},
action: {
id: 'read',
attributes: new Map()
},
environment: {
currentTime: new Date(),
ipAddress: request.ip,
attributes: new Map()
}
};
const decision = accessControl.evaluate(context);
if (decision.decision === 'permit') {
// Allow access
}
```
## Good Example
```typescript
// Correct approach: Simple, direct solution
// Simple function that does what's needed
function canUserAccessDocument(user: User, document: Document, action: 'read' | 'write' | 'delete'): boolean {
// Admin can do anything
if (user.role === 'admin') {
return true;
}
// Owner can do anything with their document
if (document.ownerId === user.id) {
return true;
}
// Check explicit permissions
const permission = document.permissions.find(p => p.userId === user.id);
if (!permission) {
return false;
}
// Check if permission level is sufficient
switch (action) {
case 'read':
return ['read', 'write', 'admin'].includes(permission.level);
case 'write':
return ['write', 'admin'].includes(permission.level);
case 'delete':
return permission.level === 'admin';
default:
return false;
}
}
// Usage is straightforward
if (canUserAccessDocument(user, document, 'read')) {
// Allow access
}
// If requirements grow, evolve the solution incrementally
// Add time-based access when actually needed
function canUserAccessDocument(
user: User,
document: Document,
action: 'read' | 'write' | 'delete'
): boolean {
// Admin can do anything
if (user.role === 'admin') {
return true;
}
// Owner can do anything with their document
if (document.ownerId === user.id) {
return true;
}
// Check explicit permissions
const permission = document.permissions.find(p => p.userId === user.id);
if (!permission) {
return false;
}
// Check expiration (added when actually needed)
if (permission.expiresAt && permission.expiresAt < new Date()) {
return false;
}
// Check if permission level is sufficient
const requiredLevel = getRequiredLevel(action);
return hasRequiredLevel(permission.level, requiredLevel);
}
function getRequiredLevel(action: 'read' | 'write' | 'delete'): PermissionLevel {
const levels: Record<string, PermissionLevel> = {
read: 'read',
write: 'write',
delete: 'admin'
};
return levels[action];
}
function hasRequiredLevel(userLevel: PermissionLevel, required: PermissionLevel): boolean {
const hierarchy: PermissionLevel[] = ['read', 'write', 'admin'];
return hierarchy.indexOf(userLevel) >= hierarchy.indexOf(required);
}
// Still simple, still readable, handles new requirement
// For multiple resources, create focused helper
class DocumentAccessChecker {
canRead(user: User, document: Document): boolean {
return canUserAccessDocument(user, document, 'read');
}
canWrite(user: User, document: Document): boolean {
return canUserAccessDocument(user, document, 'write');
}
canDelete(user: User, document: Document): boolean {
return canUserAccessDocument(user, document, 'delete');
}
// Filter a list of documents to only accessible ones
filterReadable(user: User, documents: Document[]): Document[] {
return documents.filter(doc => this.canRead(user, doc));
}
}
// Usage remains simple
const checker = new DocumentAccessChecker();
if (checker.canRead(user, document)) {
// Show document
}
const accessibleDocs = checker.filterReadable(user, allDocuments);
```
## Why
1. **Readability**: Simple code can be understood in seconds. Complex abstractions require studying.
2. **Maintainability**: New developers can work with simple code immediately. Complex frameworks need training.
3. **Debugging**: When something breaks, simple code has obvious failure points.
4. **Performance**: Simple code is often faster - fewer layers, fewer allocations, less indirection.
5. **Time to Market**: Simple solutions are built and shipped faster.
6. **YAGNI Alignment**: The complex solution solves problems you don't have (and may never have).
7. **Incremental Complexity**: Start simple, add complexity only when real requirements demand it. The simple solution can evolve.

View File

@@ -0,0 +1,321 @@
---
id: core-law-demeter
title: Law of Demeter
category: core-principles
priority: critical
tags: [law-of-demeter, coupling, encapsulation]
related: [core-encapsulation, solid-srp-class, core-separation-concerns]
---
# Law of Demeter
A method should only talk to its immediate friends, not to strangers. Don't reach through objects to access their internal structure. This reduces coupling and makes code more maintainable.
## Bad Example
```typescript
// Anti-pattern: Reaching through object chains
class Address {
street: string;
city: string;
country: Country;
}
class Country {
name: string;
code: string;
taxRules: TaxRules;
}
class TaxRules {
vatRate: number;
calculateTax(amount: number): number {
return amount * this.vatRate;
}
}
class Customer {
name: string;
address: Address;
wallet: Wallet;
}
class Wallet {
balance: number;
currency: Currency;
deduct(amount: number): void {
this.balance -= amount;
}
}
class Currency {
code: string;
exchangeRate: number;
}
class Order {
customer: Customer;
items: OrderItem[];
// Violation: Reaching deep into customer's structure
getCustomerCountry(): string {
return this.customer.address.country.name; // 4 levels deep!
}
// Violation: Reaching into customer's wallet
calculateTax(): number {
const amount = this.getTotal();
// Reaching through customer -> address -> country -> taxRules
return this.customer.address.country.taxRules.calculateTax(amount);
}
// Violation: Manipulating customer's wallet directly
processPayment(): void {
const total = this.calculateTax() + this.getTotal();
// Reaching into wallet to check and modify
if (this.customer.wallet.balance < total) {
throw new Error('Insufficient funds');
}
// Reaching into wallet's currency for conversion
const exchangeRate = this.customer.wallet.currency.exchangeRate;
const convertedAmount = total * exchangeRate;
this.customer.wallet.deduct(convertedAmount);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
// Problems with this approach:
// 1. Order knows too much about Customer's internal structure
// 2. Changes to Address, Country, or Wallet break Order
// 3. Hard to test - must mock entire object graph
// 4. Tight coupling between unrelated classes
```
## Good Example
```typescript
// Correct approach: Talk only to immediate friends
class Address {
private street: string;
private city: string;
private country: Country;
constructor(street: string, city: string, country: Country) {
this.street = street;
this.city = city;
this.country = country;
}
getCountryName(): string {
return this.country.getName();
}
calculateTax(amount: number): number {
return this.country.calculateTax(amount);
}
}
class Country {
private name: string;
private code: string;
private taxRules: TaxRules;
constructor(name: string, code: string, taxRules: TaxRules) {
this.name = name;
this.code = code;
this.taxRules = taxRules;
}
getName(): string {
return this.name;
}
getCode(): string {
return this.code;
}
calculateTax(amount: number): number {
return this.taxRules.calculate(amount);
}
}
class TaxRules {
private vatRate: number;
constructor(vatRate: number) {
this.vatRate = vatRate;
}
calculate(amount: number): number {
return amount * this.vatRate;
}
}
class Wallet {
private balance: number;
private currency: Currency;
constructor(balance: number, currency: Currency) {
this.balance = balance;
this.currency = currency;
}
canAfford(amount: number): boolean {
const convertedAmount = this.currency.convert(amount);
return this.balance >= convertedAmount;
}
pay(amount: number): PaymentResult {
const convertedAmount = this.currency.convert(amount);
if (!this.canAfford(amount)) {
return { success: false, error: 'Insufficient funds' };
}
this.balance -= convertedAmount;
return { success: true, amountPaid: convertedAmount };
}
getBalance(): number {
return this.balance;
}
}
class Currency {
private code: string;
private exchangeRate: number;
constructor(code: string, exchangeRate: number) {
this.code = code;
this.exchangeRate = exchangeRate;
}
convert(amount: number): number {
return amount * this.exchangeRate;
}
}
class Customer {
private name: string;
private address: Address;
private wallet: Wallet;
constructor(name: string, address: Address, wallet: Wallet) {
this.name = name;
this.address = address;
this.wallet = wallet;
}
getName(): string {
return this.name;
}
getCountryName(): string {
return this.address.getCountryName();
}
calculateTaxFor(amount: number): number {
return this.address.calculateTax(amount);
}
canAfford(amount: number): boolean {
return this.wallet.canAfford(amount);
}
pay(amount: number): PaymentResult {
return this.wallet.pay(amount);
}
}
class Order {
private customer: Customer;
private items: OrderItem[];
constructor(customer: Customer, items: OrderItem[]) {
this.customer = customer;
this.items = items;
}
// Only talks to immediate friend (customer)
getCustomerCountry(): string {
return this.customer.getCountryName();
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.getPrice(), 0);
}
// Asks customer to calculate tax (customer knows how)
calculateTax(): number {
return this.customer.calculateTaxFor(this.getTotal());
}
getTotalWithTax(): number {
return this.getTotal() + this.calculateTax();
}
// Asks customer to pay (customer handles wallet)
processPayment(): PaymentResult {
const total = this.getTotalWithTax();
if (!this.customer.canAfford(total)) {
return { success: false, error: 'Insufficient funds' };
}
return this.customer.pay(total);
}
}
// Usage
const order = new Order(customer, items);
const result = order.processPayment();
if (result.success) {
console.log(`Payment successful: ${result.amountPaid}`);
} else {
console.log(`Payment failed: ${result.error}`);
}
// Testing is now easy - only mock the immediate friend
describe('Order', () => {
it('should process payment through customer', () => {
const mockCustomer: Customer = {
getCountryName: () => 'USA',
calculateTaxFor: (amount: number) => amount * 0.1,
canAfford: () => true,
pay: jest.fn().mockReturnValue({ success: true, amountPaid: 110 })
} as any;
const order = new Order(mockCustomer, [{ getPrice: () => 100 }]);
const result = order.processPayment();
expect(result.success).toBe(true);
expect(mockCustomer.pay).toHaveBeenCalledWith(110);
});
});
```
## Why
1. **Reduced Coupling**: Order only knows about Customer. Changes to Address, Country, or Wallet don't affect Order.
2. **Encapsulation**: Internal structure is hidden. Customer can change how it stores address without affecting clients.
3. **Easier Testing**: Mock only the immediate friend. No need to construct deep object graphs.
4. **Better Abstraction**: Customer is responsible for customer things. Order doesn't need to know about wallets.
5. **Maintainability**: When requirements change, changes are localized to the responsible class.
6. **Readability**: `customer.calculateTaxFor(amount)` is clearer than `customer.address.country.taxRules.calculateTax(amount)`.
7. **Flexibility**: Can change internal implementations without affecting clients. Customer could switch from wallet to payment service.

View File

@@ -0,0 +1,337 @@
---
id: core-separation-concerns
title: Separation of Concerns
category: core-principles
priority: critical
tags: [separation-of-concerns, modularity, cohesion]
related: [solid-srp-class, solid-srp-function, core-law-demeter]
---
# Separation of Concerns
Different concerns should be handled by different parts of the system. Each module, class, or function should address a single concern, making the code easier to understand, test, and modify.
## Bad Example
```typescript
// Anti-pattern: Multiple concerns mixed together
class OrderProcessor {
async processOrder(orderData: any, request: Request): Promise<Response> {
// Concern 1: HTTP request parsing
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
const token = authHeader.slice(7);
// Concern 2: Authentication
let userId: string;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
userId = decoded.userId;
} catch {
return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 });
}
// Concern 3: Validation
if (!orderData.items || orderData.items.length === 0) {
return new Response(JSON.stringify({ error: 'Order must have items' }), { status: 400 });
}
for (const item of orderData.items) {
if (!item.productId || item.quantity < 1) {
return new Response(JSON.stringify({ error: 'Invalid item' }), { status: 400 });
}
}
// Concern 4: Database access
const connection = await mysql.createConnection(process.env.DATABASE_URL!);
try {
// Concern 5: Business logic - inventory check
for (const item of orderData.items) {
const [rows] = await connection.execute(
'SELECT stock FROM products WHERE id = ?',
[item.productId]
);
if (rows[0].stock < item.quantity) {
return new Response(
JSON.stringify({ error: `Insufficient stock for ${item.productId}` }),
{ status: 400 }
);
}
}
// Concern 6: Business logic - pricing
let total = 0;
for (const item of orderData.items) {
const [rows] = await connection.execute(
'SELECT price FROM products WHERE id = ?',
[item.productId]
);
total += rows[0].price * item.quantity;
}
// Concern 7: Business logic - create order
const orderId = crypto.randomUUID();
await connection.execute(
'INSERT INTO orders (id, user_id, total, status) VALUES (?, ?, ?, ?)',
[orderId, userId, total, 'pending']
);
// Concern 8: Payment processing
const stripe = new Stripe(process.env.STRIPE_KEY!);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(total * 100),
currency: 'usd',
customer: userId
});
// Concern 9: Inventory update
for (const item of orderData.items) {
await connection.execute(
'UPDATE products SET stock = stock - ? WHERE id = ?',
[item.quantity, item.productId]
);
}
// Concern 10: Notification
const transporter = nodemailer.createTransport({ /* SMTP config */ });
await transporter.sendMail({
to: orderData.email,
subject: 'Order Confirmation',
html: `<h1>Order ${orderId} confirmed</h1><p>Total: $${total}</p>`
});
// Concern 11: Logging
console.log(`Order ${orderId} created for user ${userId}`);
// Concern 12: HTTP response formatting
return new Response(
JSON.stringify({ orderId, total, paymentIntentId: paymentIntent.id }),
{ status: 201, headers: { 'Content-Type': 'application/json' } }
);
} finally {
await connection.end();
}
}
}
```
## Good Example
```typescript
// Correct approach: Each concern in its own module
// Concern: HTTP handling
class OrderController {
constructor(private orderService: OrderService) {}
async createOrder(req: Request, res: Response): Promise<void> {
try {
const userId = req.user.id; // Auth middleware already handled this
const orderData = req.body; // Validation middleware already validated
const order = await this.orderService.createOrder(userId, orderData);
res.status(201).json({
orderId: order.id,
total: order.total,
status: order.status
});
} catch (error) {
if (error instanceof InsufficientStockError) {
res.status(400).json({ error: error.message });
} else if (error instanceof PaymentFailedError) {
res.status(402).json({ error: 'Payment failed' });
} else {
throw error; // Let error middleware handle
}
}
}
}
// Concern: Authentication (middleware)
class AuthMiddleware {
constructor(private authService: AuthService) {}
async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = this.extractToken(req);
if (!token) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const user = await this.authService.validateToken(token);
if (!user) {
res.status(401).json({ error: 'Invalid token' });
return;
}
req.user = user;
next();
}
private extractToken(req: Request): string | null {
const header = req.headers.authorization;
return header?.startsWith('Bearer ') ? header.slice(7) : null;
}
}
// Concern: Validation (separate validator)
class OrderValidator {
validate(data: unknown): CreateOrderData {
const schema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive()
})).min(1)
});
return schema.parse(data);
}
}
// Concern: Business logic orchestration
class OrderService {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private pricingService: PricingService,
private paymentService: PaymentService,
private notificationService: NotificationService,
private logger: Logger
) {}
async createOrder(userId: string, data: CreateOrderData): Promise<Order> {
// Check inventory
await this.inventoryService.checkAvailability(data.items);
// Calculate pricing
const pricing = await this.pricingService.calculateTotal(data.items);
// Create order
const order = await this.orderRepository.create({
userId,
items: data.items,
total: pricing.total,
status: 'pending'
});
// Process payment
await this.paymentService.charge(userId, pricing.total, order.id);
// Reserve inventory
await this.inventoryService.reserve(data.items);
// Send confirmation
await this.notificationService.sendOrderConfirmation(order);
// Log
this.logger.info('Order created', { orderId: order.id, userId });
return order;
}
}
// Concern: Inventory management
class InventoryService {
constructor(private productRepository: ProductRepository) {}
async checkAvailability(items: OrderItem[]): Promise<void> {
for (const item of items) {
const product = await this.productRepository.findById(item.productId);
if (product.stock < item.quantity) {
throw new InsufficientStockError(item.productId, product.stock, item.quantity);
}
}
}
async reserve(items: OrderItem[]): Promise<void> {
for (const item of items) {
await this.productRepository.decrementStock(item.productId, item.quantity);
}
}
}
// Concern: Pricing calculation
class PricingService {
constructor(private productRepository: ProductRepository) {}
async calculateTotal(items: OrderItem[]): Promise<PricingResult> {
let subtotal = 0;
for (const item of items) {
const product = await this.productRepository.findById(item.productId);
subtotal += product.price * item.quantity;
}
const tax = this.calculateTax(subtotal);
const total = subtotal + tax;
return { subtotal, tax, total };
}
private calculateTax(amount: number): number {
return amount * 0.1; // 10% tax
}
}
// Concern: Payment processing
class PaymentService {
constructor(private stripeClient: Stripe) {}
async charge(userId: string, amount: number, orderId: string): Promise<Payment> {
const intent = await this.stripeClient.paymentIntents.create({
amount: Math.round(amount * 100),
currency: 'usd',
metadata: { orderId }
});
return { id: intent.id, status: 'pending' };
}
}
// Concern: Notifications
class NotificationService {
constructor(private emailService: EmailService) {}
async sendOrderConfirmation(order: Order): Promise<void> {
await this.emailService.send({
to: order.userEmail,
template: 'order-confirmation',
data: { orderId: order.id, total: order.total }
});
}
}
// Concern: Data access
class OrderRepository {
constructor(private db: Database) {}
async create(data: CreateOrderInput): Promise<Order> {
return this.db.orders.create({ data });
}
async findById(id: string): Promise<Order | null> {
return this.db.orders.findUnique({ where: { id } });
}
}
```
## Why
1. **Understandability**: Each module has one job. Easy to understand what it does.
2. **Testability**: Test inventory logic without payment, test pricing without database.
3. **Reusability**: `PricingService` can be used for quotes, carts, or invoices.
4. **Maintainability**: Change email provider? Only touch `EmailService`.
5. **Team Scaling**: Different developers can work on different concerns without conflicts.
6. **Flexibility**: Swap Stripe for another payment provider by changing only `PaymentService`.
7. **Debugging**: Error in pricing? Look at `PricingService`. Clear boundaries help locate issues.

View File

@@ -0,0 +1,252 @@
---
id: core-yagni-abstractions
title: YAGNI - Abstractions
category: core-principles
priority: critical
tags: [YAGNI, premature-abstraction, simplicity]
related: [core-yagni-features, core-kiss-simplicity, solid-ocp-abstraction]
---
# YAGNI Principle - Abstractions
Don't create abstractions until you have concrete evidence they're needed. Premature abstraction leads to wrong abstractions that are worse than no abstraction.
## Bad Example
```typescript
// Anti-pattern: Creating abstractions before understanding the problem
// Task: Send an email notification
// Over-abstracted solution based on imagined future needs
// "We might need different notification channels someday"
interface NotificationChannel {
send(notification: Notification): Promise<void>;
getCapabilities(): ChannelCapabilities;
isAvailable(): Promise<boolean>;
}
// "We might need different notification types"
interface Notification {
id: string;
type: NotificationType;
priority: NotificationPriority;
payload: NotificationPayload;
metadata: NotificationMetadata;
}
// "We might need complex routing logic"
interface NotificationRouter {
route(notification: Notification): Promise<NotificationChannel[]>;
registerChannel(channel: NotificationChannel): void;
setRoutingRules(rules: RoutingRule[]): void;
}
// "We might need to transform notifications per channel"
interface NotificationTransformer {
transform(notification: Notification, channel: NotificationChannel): TransformedNotification;
}
// "We might need retry logic"
interface NotificationRetryPolicy {
shouldRetry(attempt: number, error: Error): boolean;
getDelay(attempt: number): number;
}
// "We might need to track delivery"
interface NotificationTracker {
trackSent(notification: Notification, channel: NotificationChannel): Promise<void>;
trackDelivered(notificationId: string): Promise<void>;
trackFailed(notificationId: string, error: Error): Promise<void>;
}
// "We might need a notification queue"
interface NotificationQueue {
enqueue(notification: Notification): Promise<void>;
process(): Promise<void>;
getStatus(notificationId: string): Promise<QueueStatus>;
}
// Orchestrator that ties it all together
class NotificationOrchestrator {
constructor(
private router: NotificationRouter,
private transformer: NotificationTransformer,
private retryPolicy: NotificationRetryPolicy,
private tracker: NotificationTracker,
private queue: NotificationQueue
) {}
async notify(notification: Notification): Promise<void> {
await this.queue.enqueue(notification);
// 200+ lines of orchestration logic
}
}
// But all we actually needed was:
// Send an email when a user registers
// Result: 1000+ lines of abstraction, weeks of work, for sending one email
```
## Good Example
```typescript
// Correct approach: Start concrete, abstract when patterns emerge
// Task: Send an email notification
// Simple, direct solution
interface EmailOptions {
to: string;
subject: string;
body: string;
}
class EmailService {
constructor(private smtpClient: SmtpClient) {}
async send(options: EmailOptions): Promise<void> {
await this.smtpClient.send({
from: 'noreply@example.com',
to: options.to,
subject: options.subject,
html: options.body
});
}
}
// Usage
const emailService = new EmailService(smtpClient);
await emailService.send({
to: user.email,
subject: 'Welcome!',
body: '<h1>Welcome to our app!</h1>'
});
// Later, when we actually need SMS (not "might need"):
class SmsService {
constructor(private twilioClient: TwilioClient) {}
async send(phone: string, message: string): Promise<void> {
await this.twilioClient.messages.create({
to: phone,
from: process.env.TWILIO_NUMBER,
body: message
});
}
}
// Now we have TWO concrete implementations
// We can see what they have in common
// Abstract AFTER seeing the pattern (Rule of Three)
// After email, SMS, and push notifications exist:
interface NotificationSender {
send(recipient: string, message: NotificationMessage): Promise<void>;
}
interface NotificationMessage {
subject?: string;
body: string;
}
class EmailNotificationSender implements NotificationSender {
constructor(private emailService: EmailService) {}
async send(recipient: string, message: NotificationMessage): Promise<void> {
await this.emailService.send({
to: recipient,
subject: message.subject || 'Notification',
body: message.body
});
}
}
class SmsNotificationSender implements NotificationSender {
constructor(private smsService: SmsService) {}
async send(recipient: string, message: NotificationMessage): Promise<void> {
// SMS doesn't support subject, so we just use body
await this.smsService.send(recipient, message.body);
}
}
class PushNotificationSender implements NotificationSender {
constructor(private pushService: PushService) {}
async send(recipient: string, message: NotificationMessage): Promise<void> {
await this.pushService.send(recipient, {
title: message.subject,
body: message.body
});
}
}
// Simple notification service that uses the abstraction
class NotificationService {
constructor(private senders: Map<string, NotificationSender>) {}
async notify(
channel: 'email' | 'sms' | 'push',
recipient: string,
message: NotificationMessage
): Promise<void> {
const sender = this.senders.get(channel);
if (!sender) {
throw new Error(`Unknown notification channel: ${channel}`);
}
await sender.send(recipient, message);
}
}
// The abstraction fits because it was derived from concrete implementations
// It's minimal - just what's needed, nothing speculative
// If we later need retry logic, we add it when we have concrete requirements:
class RetryingNotificationSender implements NotificationSender {
constructor(
private sender: NotificationSender,
private maxAttempts: number = 3
) {}
async send(recipient: string, message: NotificationMessage): Promise<void> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
await this.sender.send(recipient, message);
return;
} catch (error) {
lastError = error as Error;
if (attempt < this.maxAttempts) {
await this.delay(attempt * 1000);
}
}
}
throw lastError;
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
```
## Why
1. **Wrong Abstractions Are Costly**: Premature abstractions are often wrong because you don't understand the problem yet. Wrong abstractions are harder to change than no abstractions.
2. **Rule of Three**: Wait until you have three concrete examples before abstracting. Two is often coincidence; three reveals the pattern.
3. **Duplication Is Cheaper Than Wrong Abstraction**: It's easier to extract a correct abstraction from duplicated code than to fix a wrong abstraction.
4. **Context Matters**: Abstractions made with real requirements fit better than those made speculatively.
5. **Simplicity**: Concrete code is simpler to understand, debug, and modify.
6. **Evolutionary Design**: Let the design emerge from actual needs rather than imagined ones.
7. **Time Value**: The time spent on speculative abstractions could be spent on real features.

View File

@@ -0,0 +1,205 @@
---
id: core-yagni-features
title: YAGNI - Features
category: core-principles
priority: critical
tags: [YAGNI, speculative-features, lean-development]
related: [core-yagni-abstractions, core-kiss-simplicity]
---
# YAGNI Principle - Features
You Aren't Gonna Need It. Don't implement features until they are actually required. Building speculative features wastes time and adds unnecessary complexity.
## Bad Example
```typescript
// Anti-pattern: Building features "just in case"
// Task: Create a simple user registration
// Over-built solution with features nobody asked for
interface User {
id: string;
email: string;
password: string;
// Speculative fields - nobody asked for these
phone?: string;
address?: Address;
preferences?: UserPreferences;
socialProfiles?: SocialProfile[];
twoFactorEnabled?: boolean;
twoFactorSecret?: string;
backupCodes?: string[];
apiKeys?: ApiKey[];
loginHistory?: LoginAttempt[];
sessions?: Session[];
subscriptionTier?: 'free' | 'basic' | 'premium' | 'enterprise';
billingInfo?: BillingInfo;
teamMemberships?: TeamMembership[];
referralCode?: string;
referredBy?: string;
loyaltyPoints?: number;
}
class UserService {
// Basic registration - actually needed
async register(email: string, password: string): Promise<User> {
// ...
}
// These were built "just in case" - never used
async enableTwoFactor(userId: string): Promise<TwoFactorSetup> {
// 200 lines of 2FA implementation
// Product never asked for this feature
}
async generateApiKey(userId: string, scopes: string[]): Promise<ApiKey> {
// 150 lines of API key management
// No API exists for external developers
}
async trackLoginAttempt(userId: string, success: boolean, ip: string): Promise<void> {
// 100 lines of login tracking
// No dashboard to view this data
}
async manageSessions(userId: string): Promise<Session[]> {
// 180 lines of session management
// Users can't actually see or manage sessions
}
async upgradeSubscription(userId: string, tier: string): Promise<void> {
// 300 lines of subscription logic
// App is free, no paid tiers planned
}
async processReferral(referrerCode: string, newUserId: string): Promise<void> {
// 120 lines of referral logic
// Marketing hasn't planned a referral program
}
async awardLoyaltyPoints(userId: string, action: string): Promise<void> {
// 100 lines of loyalty point logic
// No loyalty program exists
}
// 1500+ lines of code for features that may never be used
}
// Result:
// - 3 weeks spent building speculative features
// - 1500+ lines to maintain
// - Bugs introduced in code that's never executed
// - Actual requirements delayed
```
## Good Example
```typescript
// Correct approach: Build only what's needed now
// Task: Create a simple user registration
// Focused solution that meets actual requirements
interface User {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
updatedAt: Date;
}
interface CreateUserData {
email: string;
password: string;
}
class UserService {
constructor(
private userRepository: UserRepository,
private passwordHasher: PasswordHasher,
private emailService: EmailService
) {}
async register(data: CreateUserData): Promise<User> {
// Validate email
const existingUser = await this.userRepository.findByEmail(data.email);
if (existingUser) {
throw new UserAlreadyExistsError(data.email);
}
// Create user
const passwordHash = await this.passwordHasher.hash(data.password);
const user = await this.userRepository.create({
email: data.email,
passwordHash,
createdAt: new Date(),
updatedAt: new Date()
});
// Send welcome email
await this.emailService.sendWelcome(user.email);
return user;
}
async findById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findByEmail(email);
}
}
// Result:
// - Built in 2 days
// - ~100 lines of focused code
// - Easy to understand and maintain
// - Shipped to users quickly
// - Can add features when actually needed
// When 2FA is actually requested (6 months later):
// Add it then with full context of actual requirements
interface TwoFactorService {
enable(userId: string): Promise<TwoFactorSetup>;
verify(userId: string, code: string): Promise<boolean>;
disable(userId: string): Promise<void>;
}
// When subscription tiers are planned (1 year later):
// Add with actual business requirements, pricing, and features defined
interface SubscriptionService {
getCurrentPlan(userId: string): Promise<Plan>;
upgrade(userId: string, planId: string): Promise<void>;
downgrade(userId: string, planId: string): Promise<void>;
cancel(userId: string): Promise<void>;
}
// Benefits of waiting:
// 1. Requirements are clearer
// 2. You know the actual use cases
// 3. Technology may have improved
// 4. You didn't maintain unused code for months
// 5. You might not need it at all (50%+ of speculative features are never used)
```
## Why
1. **Waste Prevention**: Speculative features consume development time that could be spent on actual needs.
2. **Maintenance Burden**: Every line of code must be maintained, tested, and understood - even unused code.
3. **Complexity Cost**: Unused features add cognitive load when reading and modifying the codebase.
4. **Requirements Clarity**: Future requirements are clearer when you're actually implementing them.
5. **Flexibility**: Code without speculative features is easier to refactor and evolve.
6. **Faster Delivery**: Ship what's needed now, iterate based on real feedback.
7. **Better Design**: Features designed with real requirements and user feedback are better than guessed features.

View File

@@ -0,0 +1,359 @@
---
id: pattern-repository
title: Design Pattern - Repository
category: design-patterns
priority: high
tags: [design-patterns, repository, data-access, separation-of-concerns]
related: [solid-dip-abstractions, solid-srp-class, core-separation-concerns]
---
# Repository Pattern
The Repository pattern abstracts data persistence, providing a collection-like interface for accessing domain objects. It separates business logic from data access, makes code testable, and allows swapping storage implementations without changing application code.
## Bad Example
```typescript
// ❌ Data access mixed with business logic
class OrderService {
async createOrder(userId: string, items: CartItem[]) {
// Business logic
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
// Direct database access - tightly coupled
const result = await pool.query(
`INSERT INTO orders (user_id, total, status, created_at)
VALUES ($1, $2, $3, NOW()) RETURNING *`,
[userId, total, 'pending']
);
const orderId = result.rows[0].id;
// More SQL scattered in business logic
for (const item of items) {
await pool.query(
`INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES ($1, $2, $3, $4)`,
[orderId, item.productId, item.quantity, item.price]
);
}
return result.rows[0];
}
async getOrdersByUser(userId: string) {
// SQL everywhere
const result = await pool.query(
`SELECT o.*, json_agg(oi.*) as items
FROM orders o
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1
GROUP BY o.id`,
[userId]
);
return result.rows;
}
}
```
**Problems:**
- Business logic mixed with SQL
- Hard to test without real database
- Changing database requires rewriting service
- SQL scattered across codebase
## Good Example
### Define Repository Interface
```typescript
// ✅ Repository interface - contract for data access
interface OrderRepository {
find(id: string): Promise<Order | null>;
findByUser(userId: string): Promise<Order[]>;
findByStatus(status: OrderStatus): Promise<Order[]>;
save(order: Order): Promise<Order>;
delete(id: string): Promise<void>;
}
interface OrderItemRepository {
findByOrder(orderId: string): Promise<OrderItem[]>;
saveMany(items: OrderItem[]): Promise<OrderItem[]>;
deleteByOrder(orderId: string): Promise<void>;
}
```
### Implement Repository
```typescript
// ✅ PostgreSQL implementation
class PostgresOrderRepository implements OrderRepository {
constructor(private db: Pool) {}
async find(id: string): Promise<Order | null> {
const result = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[id]
);
return result.rows[0] ? this.mapToOrder(result.rows[0]) : null;
}
async findByUser(userId: string): Promise<Order[]> {
const result = await this.db.query(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows.map(this.mapToOrder);
}
async findByStatus(status: OrderStatus): Promise<Order[]> {
const result = await this.db.query(
'SELECT * FROM orders WHERE status = $1',
[status]
);
return result.rows.map(this.mapToOrder);
}
async save(order: Order): Promise<Order> {
if (order.id) {
return this.update(order);
}
return this.insert(order);
}
private async insert(order: Order): Promise<Order> {
const result = await this.db.query(
`INSERT INTO orders (user_id, total, status, created_at)
VALUES ($1, $2, $3, NOW()) RETURNING *`,
[order.userId, order.total, order.status]
);
return this.mapToOrder(result.rows[0]);
}
private async update(order: Order): Promise<Order> {
const result = await this.db.query(
`UPDATE orders SET total = $1, status = $2, updated_at = NOW()
WHERE id = $3 RETURNING *`,
[order.total, order.status, order.id]
);
return this.mapToOrder(result.rows[0]);
}
async delete(id: string): Promise<void> {
await this.db.query('DELETE FROM orders WHERE id = $1', [id]);
}
private mapToOrder(row: any): Order {
return new Order({
id: row.id,
userId: row.user_id,
total: parseFloat(row.total),
status: row.status as OrderStatus,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
}
```
### Clean Service Layer
```typescript
// ✅ Service focuses on business logic only
class OrderService {
constructor(
private orderRepository: OrderRepository,
private orderItemRepository: OrderItemRepository,
private productRepository: ProductRepository,
) {}
async createOrder(userId: string, items: CartItem[]): Promise<Order> {
// Pure business logic
const total = this.calculateTotal(items);
const order = new Order({
userId,
total,
status: OrderStatus.Pending,
});
// Repository handles persistence
const savedOrder = await this.orderRepository.save(order);
const orderItems = items.map(item => new OrderItem({
orderId: savedOrder.id,
productId: item.productId,
quantity: item.quantity,
price: item.price,
}));
await this.orderItemRepository.saveMany(orderItems);
return savedOrder;
}
async cancelOrder(orderId: string): Promise<Order> {
const order = await this.orderRepository.find(orderId);
if (!order) {
throw new OrderNotFoundException(orderId);
}
if (!order.canBeCancelled()) {
throw new InvalidOrderStateException('Order cannot be cancelled');
}
order.cancel();
return this.orderRepository.save(order);
}
private calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
```
### In-Memory Repository for Testing
```typescript
// ✅ In-memory implementation for tests
class InMemoryOrderRepository implements OrderRepository {
private orders: Map<string, Order> = new Map();
private idCounter = 1;
async find(id: string): Promise<Order | null> {
return this.orders.get(id) ?? null;
}
async findByUser(userId: string): Promise<Order[]> {
return Array.from(this.orders.values())
.filter(order => order.userId === userId);
}
async findByStatus(status: OrderStatus): Promise<Order[]> {
return Array.from(this.orders.values())
.filter(order => order.status === status);
}
async save(order: Order): Promise<Order> {
if (!order.id) {
order.id = String(this.idCounter++);
}
this.orders.set(order.id, order);
return order;
}
async delete(id: string): Promise<void> {
this.orders.delete(id);
}
// Test helper methods
clear(): void {
this.orders.clear();
}
seed(orders: Order[]): void {
orders.forEach(order => this.orders.set(order.id, order));
}
}
```
### Easy Testing
```typescript
// ✅ Unit tests without database
describe('OrderService', () => {
let orderService: OrderService;
let orderRepository: InMemoryOrderRepository;
let orderItemRepository: InMemoryOrderItemRepository;
beforeEach(() => {
orderRepository = new InMemoryOrderRepository();
orderItemRepository = new InMemoryOrderItemRepository();
orderService = new OrderService(
orderRepository,
orderItemRepository,
new InMemoryProductRepository(),
);
});
it('creates order with calculated total', async () => {
const items = [
{ productId: '1', quantity: 2, price: 10 },
{ productId: '2', quantity: 1, price: 25 },
];
const order = await orderService.createOrder('user-1', items);
expect(order.total).toBe(45);
expect(order.status).toBe(OrderStatus.Pending);
});
it('cancels pending order', async () => {
orderRepository.seed([
new Order({ id: '1', userId: 'user-1', status: OrderStatus.Pending }),
]);
const order = await orderService.cancelOrder('1');
expect(order.status).toBe(OrderStatus.Cancelled);
});
it('throws when cancelling shipped order', async () => {
orderRepository.seed([
new Order({ id: '1', userId: 'user-1', status: OrderStatus.Shipped }),
]);
await expect(orderService.cancelOrder('1'))
.rejects.toThrow(InvalidOrderStateException);
});
});
```
### Generic Repository Base
```typescript
// ✅ Generic base for common operations
interface Repository<T, ID> {
find(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
exists(id: ID): Promise<boolean>;
}
abstract class BasePostgresRepository<T, ID> implements Repository<T, ID> {
constructor(
protected db: Pool,
protected tableName: string,
) {}
async find(id: ID): Promise<T | null> {
const result = await this.db.query(
`SELECT * FROM ${this.tableName} WHERE id = $1`,
[id]
);
return result.rows[0] ? this.mapToEntity(result.rows[0]) : null;
}
async exists(id: ID): Promise<boolean> {
const result = await this.db.query(
`SELECT 1 FROM ${this.tableName} WHERE id = $1`,
[id]
);
return result.rows.length > 0;
}
protected abstract mapToEntity(row: any): T;
}
```
## Why
- Separation of concerns
- Business logic free of persistence details
- Easy to test with in-memory implementations
- Swap databases without changing services
- Centralized query logic
- Consistent data access patterns
- Single place to add caching, logging

View File

@@ -0,0 +1,336 @@
---
id: solid-dip-abstractions
title: SOLID - Dependency Inversion (Abstractions)
category: solid-principles
priority: critical
tags: [SOLID, DIP, dependency-inversion, abstractions]
related: [solid-dip-injection, solid-ocp-abstraction, pattern-repository]
---
# Dependency Inversion Principle - Depend on Abstractions
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
## Bad Example
```typescript
// Anti-pattern: High-level module depends on low-level implementation details
// Low-level modules (concrete implementations)
class MySQLDatabase {
connect(): void {
console.log('Connecting to MySQL...');
}
query(sql: string): any[] {
console.log(`Executing MySQL query: ${sql}`);
return [];
}
close(): void {
console.log('Closing MySQL connection');
}
}
class SmtpEmailSender {
send(to: string, subject: string, body: string): void {
console.log(`Sending SMTP email to ${to}`);
}
}
class StripePaymentGateway {
charge(amount: number, cardToken: string): string {
console.log(`Charging $${amount} via Stripe`);
return 'stripe_txn_123';
}
}
// High-level module directly depends on low-level implementations
class OrderService {
private database: MySQLDatabase;
private emailSender: SmtpEmailSender;
private paymentGateway: StripePaymentGateway;
constructor() {
// Direct instantiation creates tight coupling
this.database = new MySQLDatabase();
this.emailSender = new SmtpEmailSender();
this.paymentGateway = new StripePaymentGateway();
}
async createOrder(orderData: OrderData): Promise<Order> {
this.database.connect();
// MySQL-specific query
const result = this.database.query(
`INSERT INTO orders (customer_id, total) VALUES (${orderData.customerId}, ${orderData.total})`
);
// Stripe-specific charging
const txnId = this.paymentGateway.charge(orderData.total, orderData.cardToken);
// SMTP-specific sending
this.emailSender.send(
orderData.customerEmail,
'Order Confirmation',
`Your order has been placed. Transaction: ${txnId}`
);
this.database.close();
return { id: 'order_1', ...orderData };
}
}
// Problems:
// 1. Cannot switch to PostgreSQL without modifying OrderService
// 2. Cannot use SendGrid for emails without modifying OrderService
// 3. Cannot test without real MySQL, SMTP, and Stripe
// 4. OrderService knows implementation details it shouldn't
```
## Good Example
```typescript
// Correct approach: Depend on abstractions, not concretions
// Abstractions (interfaces) - owned by the high-level module
interface Database {
connect(): Promise<void>;
query<T>(query: QueryBuilder): Promise<T[]>;
execute(command: Command): Promise<void>;
disconnect(): Promise<void>;
}
interface QueryBuilder {
table: string;
select?: string[];
where?: Record<string, any>;
orderBy?: string;
limit?: number;
}
interface Command {
table: string;
operation: 'insert' | 'update' | 'delete';
data?: Record<string, any>;
where?: Record<string, any>;
}
interface EmailSender {
send(options: EmailOptions): Promise<void>;
}
interface EmailOptions {
to: string;
subject: string;
body: string;
html?: string;
}
interface PaymentGateway {
charge(request: ChargeRequest): Promise<ChargeResult>;
refund(transactionId: string, amount?: number): Promise<RefundResult>;
}
interface ChargeRequest {
amount: number;
currency: string;
paymentMethodId: string;
metadata?: Record<string, string>;
}
interface ChargeResult {
transactionId: string;
status: 'success' | 'pending' | 'failed';
amount: number;
}
// High-level module depends only on abstractions
class OrderService {
constructor(
private database: Database,
private emailSender: EmailSender,
private paymentGateway: PaymentGateway
) {}
async createOrder(orderData: OrderData): Promise<Order> {
await this.database.connect();
try {
// Uses abstraction - no SQL dialect specifics
await this.database.execute({
table: 'orders',
operation: 'insert',
data: {
customerId: orderData.customerId,
total: orderData.total,
status: 'pending'
}
});
// Uses abstraction - no payment provider specifics
const chargeResult = await this.paymentGateway.charge({
amount: orderData.total,
currency: 'USD',
paymentMethodId: orderData.paymentMethodId,
metadata: { orderId: orderData.id }
});
// Uses abstraction - no email provider specifics
await this.emailSender.send({
to: orderData.customerEmail,
subject: 'Order Confirmation',
body: `Your order has been placed. Transaction: ${chargeResult.transactionId}`
});
return { id: orderData.id, ...orderData, status: 'completed' };
} finally {
await this.database.disconnect();
}
}
}
// Low-level implementations depend on abstractions
class MySQLDatabase implements Database {
private connection: any;
async connect(): Promise<void> {
this.connection = await mysql.createConnection(config);
}
async query<T>(query: QueryBuilder): Promise<T[]> {
const sql = this.buildSelectQuery(query);
const [rows] = await this.connection.execute(sql);
return rows as T[];
}
async execute(command: Command): Promise<void> {
const sql = this.buildCommandQuery(command);
await this.connection.execute(sql);
}
async disconnect(): Promise<void> {
await this.connection.end();
}
private buildSelectQuery(query: QueryBuilder): string {
// MySQL-specific query building
return `SELECT ${query.select?.join(', ') ?? '*'} FROM ${query.table}`;
}
private buildCommandQuery(command: Command): string {
// MySQL-specific command building
return `INSERT INTO ${command.table} ...`;
}
}
class PostgreSQLDatabase implements Database {
// PostgreSQL-specific implementation of the same interface
async connect(): Promise<void> { /* PostgreSQL connection */ }
async query<T>(query: QueryBuilder): Promise<T[]> { /* PostgreSQL query */ }
async execute(command: Command): Promise<void> { /* PostgreSQL execute */ }
async disconnect(): Promise<void> { /* PostgreSQL disconnect */ }
}
class SendGridEmailSender implements EmailSender {
constructor(private apiKey: string) {}
async send(options: EmailOptions): Promise<void> {
await sendgrid.send({
to: options.to,
from: 'noreply@example.com',
subject: options.subject,
text: options.body,
html: options.html
});
}
}
class StripePaymentGateway implements PaymentGateway {
constructor(private stripe: Stripe) {}
async charge(request: ChargeRequest): Promise<ChargeResult> {
const intent = await this.stripe.paymentIntents.create({
amount: request.amount * 100,
currency: request.currency,
payment_method: request.paymentMethodId,
confirm: true
});
return {
transactionId: intent.id,
status: intent.status === 'succeeded' ? 'success' : 'pending',
amount: request.amount
};
}
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
const refund = await this.stripe.refunds.create({
payment_intent: transactionId,
amount: amount ? amount * 100 : undefined
});
return { refundId: refund.id, status: 'success' };
}
}
// Composition root - where we wire everything together
function createOrderService(): OrderService {
const database = new PostgreSQLDatabase();
const emailSender = new SendGridEmailSender(process.env.SENDGRID_API_KEY!);
const paymentGateway = new StripePaymentGateway(new Stripe(process.env.STRIPE_KEY));
return new OrderService(database, emailSender, paymentGateway);
}
// Testing with mock implementations
class MockDatabase implements Database {
public queries: QueryBuilder[] = [];
public commands: Command[] = [];
async connect(): Promise<void> {}
async query<T>(query: QueryBuilder): Promise<T[]> {
this.queries.push(query);
return [];
}
async execute(command: Command): Promise<void> {
this.commands.push(command);
}
async disconnect(): Promise<void> {}
}
// Test without real databases, email servers, or payment gateways
describe('OrderService', () => {
it('should create order', async () => {
const mockDb = new MockDatabase();
const mockEmail: EmailSender = { send: jest.fn() };
const mockPayment: PaymentGateway = {
charge: jest.fn().mockResolvedValue({ transactionId: 'test_txn', status: 'success', amount: 100 }),
refund: jest.fn()
};
const service = new OrderService(mockDb, mockEmail, mockPayment);
await service.createOrder(testOrderData);
expect(mockDb.commands).toHaveLength(1);
expect(mockPayment.charge).toHaveBeenCalled();
expect(mockEmail.send).toHaveBeenCalled();
});
});
```
## Why
1. **Loose Coupling**: `OrderService` doesn't know or care about MySQL, Stripe, or SendGrid. It works with any implementation.
2. **Easy Swapping**: Switch from MySQL to PostgreSQL by providing a different implementation. No changes to `OrderService`.
3. **Testability**: Test with mock implementations - no real databases or external services needed.
4. **Parallel Development**: Teams can work on different implementations simultaneously against the same interface.
5. **Policy/Detail Separation**: Business rules (OrderService) are separate from infrastructure details (MySQLDatabase).
6. **Stable Architecture**: Abstractions change less frequently than implementations. The core is protected from change.
7. **Plugin Architecture**: New implementations can be added without modifying existing code.

View File

@@ -0,0 +1,349 @@
---
id: solid-dip-injection
title: SOLID - Dependency Inversion (Injection)
category: solid-principles
priority: critical
tags: [SOLID, DIP, dependency-injection, testability]
related: [solid-dip-abstractions, solid-srp-class, core-composition]
---
# Dependency Inversion Principle - Dependency Injection
Dependencies should be injected from outside rather than created inside a class. This enables loose coupling, testability, and flexibility in how dependencies are provided.
## Bad Example
```typescript
// Anti-pattern: Creating dependencies inside the class
class UserRegistrationService {
private userRepository: UserRepository;
private passwordHasher: PasswordHasher;
private emailService: EmailService;
private logger: Logger;
private analyticsService: AnalyticsService;
constructor() {
// Dependencies created inside - tight coupling
this.userRepository = new PostgresUserRepository(
new PostgresConnection('localhost', 5432, 'mydb')
);
this.passwordHasher = new BcryptPasswordHasher(10);
this.emailService = new SendGridEmailService(process.env.SENDGRID_KEY!);
this.logger = new WinstonLogger('UserRegistration');
this.analyticsService = new MixpanelAnalytics(process.env.MIXPANEL_TOKEN!);
}
async register(email: string, password: string): Promise<User> {
this.logger.info(`Registering user: ${email}`);
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('User already exists');
}
const hashedPassword = await this.passwordHasher.hash(password);
const user = await this.userRepository.create({ email, password: hashedPassword });
await this.emailService.send({
to: email,
subject: 'Welcome!',
body: 'Thanks for registering'
});
await this.analyticsService.track('user_registered', { userId: user.id });
return user;
}
}
// Problems:
// 1. Cannot test without real Postgres, SendGrid, Mixpanel
// 2. Cannot reuse with different implementations
// 3. Hard-coded configuration scattered through constructors
// 4. Class responsible for creating its dependencies
```
## Good Example
```typescript
// Correct approach: Dependencies injected from outside
// Interfaces for all dependencies
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
findById(id: string): Promise<User | null>;
create(data: CreateUserData): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
}
interface PasswordHasher {
hash(password: string): Promise<string>;
verify(password: string, hash: string): Promise<boolean>;
}
interface EmailService {
send(options: EmailOptions): Promise<void>;
}
interface Logger {
info(message: string, meta?: Record<string, any>): void;
error(message: string, error?: Error, meta?: Record<string, any>): void;
warn(message: string, meta?: Record<string, any>): void;
}
interface AnalyticsService {
track(event: string, properties?: Record<string, any>): Promise<void>;
identify(userId: string, traits?: Record<string, any>): Promise<void>;
}
// Service with constructor injection
class UserRegistrationService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordHasher: PasswordHasher,
private readonly emailService: EmailService,
private readonly logger: Logger,
private readonly analyticsService: AnalyticsService
) {}
async register(email: string, password: string): Promise<User> {
this.logger.info('Registering user', { email });
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new UserAlreadyExistsError(email);
}
const hashedPassword = await this.passwordHasher.hash(password);
const user = await this.userRepository.create({
email,
password: hashedPassword,
createdAt: new Date()
});
await this.sendWelcomeEmail(user);
await this.trackRegistration(user);
this.logger.info('User registered successfully', { userId: user.id });
return user;
}
private async sendWelcomeEmail(user: User): Promise<void> {
try {
await this.emailService.send({
to: user.email,
subject: 'Welcome!',
body: 'Thanks for registering'
});
} catch (error) {
this.logger.error('Failed to send welcome email', error as Error, { userId: user.id });
// Don't fail registration if email fails
}
}
private async trackRegistration(user: User): Promise<void> {
try {
await this.analyticsService.identify(user.id, { email: user.email });
await this.analyticsService.track('user_registered', { userId: user.id });
} catch (error) {
this.logger.error('Failed to track registration', error as Error, { userId: user.id });
// Don't fail registration if analytics fails
}
}
}
// Concrete implementations
class PostgresUserRepository implements UserRepository {
constructor(private db: DatabaseConnection) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE email = $1', [email]);
return result.rows[0] || null;
}
async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] || null;
}
async create(data: CreateUserData): Promise<User> {
const result = await this.db.query(
'INSERT INTO users (email, password, created_at) VALUES ($1, $2, $3) RETURNING *',
[data.email, data.password, data.createdAt]
);
return result.rows[0];
}
async update(id: string, data: Partial<User>): Promise<User> {
// Implementation
}
}
class BcryptPasswordHasher implements PasswordHasher {
constructor(private rounds: number = 10) {}
async hash(password: string): Promise<string> {
return bcrypt.hash(password, this.rounds);
}
async verify(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}
// Dependency Injection Container (manual)
class Container {
private services: Map<string, any> = new Map();
register<T>(key: string, factory: () => T): void {
this.services.set(key, factory);
}
resolve<T>(key: string): T {
const factory = this.services.get(key);
if (!factory) {
throw new Error(`Service not registered: ${key}`);
}
return factory();
}
}
// Composition root - wire up all dependencies
function configureContainer(): Container {
const container = new Container();
// Register infrastructure
container.register('DatabaseConnection', () =>
new PostgresConnection(process.env.DATABASE_URL!)
);
container.register('Logger', () =>
new WinstonLogger({ level: process.env.LOG_LEVEL || 'info' })
);
// Register repositories
container.register('UserRepository', () =>
new PostgresUserRepository(container.resolve('DatabaseConnection'))
);
// Register services
container.register('PasswordHasher', () =>
new BcryptPasswordHasher(12)
);
container.register('EmailService', () =>
new SendGridEmailService(process.env.SENDGRID_API_KEY!)
);
container.register('AnalyticsService', () =>
new MixpanelAnalytics(process.env.MIXPANEL_TOKEN!)
);
// Register application services
container.register('UserRegistrationService', () =>
new UserRegistrationService(
container.resolve('UserRepository'),
container.resolve('PasswordHasher'),
container.resolve('EmailService'),
container.resolve('Logger'),
container.resolve('AnalyticsService')
)
);
return container;
}
// Application startup
const container = configureContainer();
const registrationService = container.resolve<UserRegistrationService>('UserRegistrationService');
// Testing is now trivial with mock implementations
describe('UserRegistrationService', () => {
let service: UserRegistrationService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockPasswordHasher: jest.Mocked<PasswordHasher>;
let mockEmailService: jest.Mocked<EmailService>;
let mockLogger: jest.Mocked<Logger>;
let mockAnalytics: jest.Mocked<AnalyticsService>;
beforeEach(() => {
mockUserRepository = {
findByEmail: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
update: jest.fn()
};
mockPasswordHasher = {
hash: jest.fn(),
verify: jest.fn()
};
mockEmailService = { send: jest.fn() };
mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() };
mockAnalytics = { track: jest.fn(), identify: jest.fn() };
service = new UserRegistrationService(
mockUserRepository,
mockPasswordHasher,
mockEmailService,
mockLogger,
mockAnalytics
);
});
it('should register a new user', async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
mockPasswordHasher.hash.mockResolvedValue('hashed_password');
mockUserRepository.create.mockResolvedValue({
id: '1',
email: 'test@example.com',
password: 'hashed_password',
createdAt: new Date()
});
const user = await service.register('test@example.com', 'password123');
expect(user.id).toBe('1');
expect(mockPasswordHasher.hash).toHaveBeenCalledWith('password123');
expect(mockEmailService.send).toHaveBeenCalled();
expect(mockAnalytics.track).toHaveBeenCalledWith('user_registered', { userId: '1' });
});
it('should throw error if user already exists', async () => {
mockUserRepository.findByEmail.mockResolvedValue({ id: '1', email: 'test@example.com' } as User);
await expect(service.register('test@example.com', 'password123'))
.rejects.toThrow(UserAlreadyExistsError);
expect(mockUserRepository.create).not.toHaveBeenCalled();
});
it('should not fail registration if email sending fails', async () => {
mockUserRepository.findByEmail.mockResolvedValue(null);
mockPasswordHasher.hash.mockResolvedValue('hashed_password');
mockUserRepository.create.mockResolvedValue({ id: '1', email: 'test@example.com' } as User);
mockEmailService.send.mockRejectedValue(new Error('SMTP error'));
const user = await service.register('test@example.com', 'password123');
expect(user.id).toBe('1');
expect(mockLogger.error).toHaveBeenCalled();
});
});
```
## Why
1. **Testability**: All dependencies can be mocked. Tests run fast without external services.
2. **Flexibility**: Switch implementations at configuration time, not code change time.
3. **Single Responsibility**: Classes focus on business logic, not on constructing dependencies.
4. **Configuration Centralization**: All wiring happens in one place (composition root), making it easy to see and change.
5. **Lifetime Management**: The container can manage singleton vs. transient instances.
6. **Environment Adaptation**: Easy to provide different implementations for dev, test, staging, and production.
7. **Explicit Dependencies**: Looking at the constructor tells you exactly what a class needs to function.

View File

@@ -0,0 +1,246 @@
---
id: solid-isp-clients
title: SOLID - Interface Segregation (Client-Specific)
category: solid-principles
priority: critical
tags: [SOLID, ISP, interface-segregation, client-design]
related: [solid-isp-interfaces, solid-srp-class, solid-lsp-contracts]
---
# Interface Segregation Principle - Client-Specific Interfaces
Clients should not be forced to depend on interfaces they do not use. Design interfaces from the client's perspective, not the implementation's.
## Bad Example
```typescript
// Anti-pattern: Fat interface that forces clients to depend on methods they don't use
interface UserService {
// Authentication
login(email: string, password: string): Promise<AuthToken>;
logout(userId: string): Promise<void>;
refreshToken(token: string): Promise<AuthToken>;
validateToken(token: string): Promise<boolean>;
// User management
createUser(data: CreateUserData): Promise<User>;
updateUser(id: string, data: UpdateUserData): Promise<User>;
deleteUser(id: string): Promise<void>;
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
// Profile
updateProfile(userId: string, profile: ProfileData): Promise<Profile>;
uploadAvatar(userId: string, image: Buffer): Promise<string>;
getProfile(userId: string): Promise<Profile>;
// Notifications
sendNotification(userId: string, notification: Notification): Promise<void>;
getNotificationPreferences(userId: string): Promise<NotificationPrefs>;
updateNotificationPreferences(userId: string, prefs: NotificationPrefs): Promise<void>;
// Analytics
trackUserEvent(userId: string, event: AnalyticsEvent): Promise<void>;
getUserAnalytics(userId: string): Promise<UserAnalytics>;
}
// Login page only needs authentication but must depend on entire interface
class LoginPage {
constructor(private userService: UserService) {} // Depends on 15+ methods!
async handleLogin(email: string, password: string): Promise<void> {
// Only uses 1 method
const token = await this.userService.login(email, password);
this.storeToken(token);
}
}
// Profile component forced to depend on authentication and analytics
class ProfileEditor {
constructor(private userService: UserService) {} // Depends on 15+ methods!
async saveProfile(userId: string, data: ProfileData): Promise<void> {
// Only uses 2 methods
await this.userService.updateProfile(userId, data);
const profile = await this.userService.getProfile(userId);
}
}
// Mock implementation nightmare for testing
class MockUserService implements UserService {
// Must implement ALL methods even for simple tests
login = jest.fn();
logout = jest.fn();
refreshToken = jest.fn();
validateToken = jest.fn();
createUser = jest.fn();
updateUser = jest.fn();
deleteUser = jest.fn();
getUser = jest.fn();
listUsers = jest.fn();
updateProfile = jest.fn();
uploadAvatar = jest.fn();
getProfile = jest.fn();
sendNotification = jest.fn();
getNotificationPreferences = jest.fn();
updateNotificationPreferences = jest.fn();
trackUserEvent = jest.fn();
getUserAnalytics = jest.fn();
}
```
## Good Example
```typescript
// Correct approach: Client-specific interfaces
// Authentication client interface
interface Authenticator {
login(email: string, password: string): Promise<AuthToken>;
logout(userId: string): Promise<void>;
refreshToken(token: string): Promise<AuthToken>;
validateToken(token: string): Promise<boolean>;
}
// User management client interface
interface UserManager {
createUser(data: CreateUserData): Promise<User>;
updateUser(id: string, data: UpdateUserData): Promise<User>;
deleteUser(id: string): Promise<void>;
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
}
// Profile client interface
interface ProfileManager {
updateProfile(userId: string, profile: ProfileData): Promise<Profile>;
uploadAvatar(userId: string, image: Buffer): Promise<string>;
getProfile(userId: string): Promise<Profile>;
}
// Notification client interface
interface NotificationManager {
sendNotification(userId: string, notification: Notification): Promise<void>;
getNotificationPreferences(userId: string): Promise<NotificationPrefs>;
updateNotificationPreferences(userId: string, prefs: NotificationPrefs): Promise<void>;
}
// Analytics client interface
interface UserAnalyticsTracker {
trackUserEvent(userId: string, event: AnalyticsEvent): Promise<void>;
getUserAnalytics(userId: string): Promise<UserAnalytics>;
}
// Read-only user lookup for components that only need to fetch
interface UserLookup {
getUser(id: string): Promise<User>;
getProfile(userId: string): Promise<Profile>;
}
// Login page depends only on what it needs
class LoginPage {
constructor(private auth: Authenticator) {} // Only 4 methods
async handleLogin(email: string, password: string): Promise<void> {
const token = await this.auth.login(email, password);
this.storeToken(token);
}
}
// Profile editor depends only on profile operations
class ProfileEditor {
constructor(private profileManager: ProfileManager) {} // Only 3 methods
async saveProfile(userId: string, data: ProfileData): Promise<void> {
await this.profileManager.updateProfile(userId, data);
}
async loadProfile(userId: string): Promise<Profile> {
return this.profileManager.getProfile(userId);
}
}
// User display component only needs read access
class UserCard {
constructor(private userLookup: UserLookup) {} // Only 2 methods
async render(userId: string): Promise<void> {
const user = await this.userLookup.getUser(userId);
const profile = await this.userLookup.getProfile(userId);
// Render user card
}
}
// Admin panel needs user management
class AdminUserPanel {
constructor(private userManager: UserManager) {} // Only 5 methods
async deleteUserAccount(id: string): Promise<void> {
await this.userManager.deleteUser(id);
}
}
// Implementation can still implement multiple interfaces
class UserServiceImpl implements
Authenticator,
UserManager,
ProfileManager,
NotificationManager,
UserAnalyticsTracker,
UserLookup
{
// Implement all methods...
async login(email: string, password: string): Promise<AuthToken> { /* ... */ }
async logout(userId: string): Promise<void> { /* ... */ }
// ... rest of implementation
}
// Testing is now simple - only mock what you need
describe('LoginPage', () => {
it('should login user', async () => {
const mockAuth: Authenticator = {
login: jest.fn().mockResolvedValue({ token: 'abc123' }),
logout: jest.fn(),
refreshToken: jest.fn(),
validateToken: jest.fn()
};
const loginPage = new LoginPage(mockAuth);
await loginPage.handleLogin('test@example.com', 'password');
expect(mockAuth.login).toHaveBeenCalledWith('test@example.com', 'password');
});
});
describe('ProfileEditor', () => {
it('should save profile', async () => {
const mockProfileManager: ProfileManager = {
updateProfile: jest.fn().mockResolvedValue({}),
uploadAvatar: jest.fn(),
getProfile: jest.fn()
};
const editor = new ProfileEditor(mockProfileManager);
await editor.saveProfile('user1', { name: 'John' });
expect(mockProfileManager.updateProfile).toHaveBeenCalled();
});
});
```
## Why
1. **Minimal Dependencies**: Each client depends only on the methods it actually uses, reducing coupling.
2. **Easier Testing**: Mocks are small and focused. Testing `LoginPage` requires mocking 4 methods, not 15+.
3. **Better Encapsulation**: Clients can't accidentally call methods they shouldn't have access to.
4. **Clearer Intent**: Interface names describe what clients need: `Authenticator`, `ProfileManager`, `UserLookup`.
5. **Independent Evolution**: Changes to analytics don't affect authentication clients. Interfaces can evolve separately.
6. **Flexible Composition**: Different implementations can provide different subsets of functionality.
7. **Single Responsibility**: Each interface has a focused responsibility, making the system easier to understand.

View File

@@ -0,0 +1,286 @@
---
id: solid-isp-interfaces
title: SOLID - Interface Segregation (Small Interfaces)
category: solid-principles
priority: critical
tags: [SOLID, ISP, interface-segregation, cohesion]
related: [solid-isp-clients, solid-srp-class, core-separation-concerns]
---
# Interface Segregation Principle - Small Cohesive Interfaces
Interfaces should be small and cohesive, grouping only closely related methods. Split large interfaces into smaller, more focused ones.
## Bad Example
```typescript
// Anti-pattern: Large interface with unrelated methods grouped together
interface Document {
// Content operations
getContent(): string;
setContent(content: string): void;
appendContent(content: string): void;
// Persistence operations
save(): Promise<void>;
load(id: string): Promise<void>;
delete(): Promise<void>;
// Export operations
exportToPdf(): Promise<Buffer>;
exportToWord(): Promise<Buffer>;
exportToHtml(): Promise<string>;
// Collaboration operations
share(userId: string): Promise<void>;
getCollaborators(): Promise<User[]>;
addComment(comment: Comment): Promise<void>;
getComments(): Promise<Comment[]>;
// Version control
createVersion(): Promise<Version>;
getVersionHistory(): Promise<Version[]>;
revertToVersion(versionId: string): Promise<void>;
// Permissions
setPermissions(permissions: Permissions): Promise<void>;
getPermissions(): Promise<Permissions>;
checkPermission(userId: string, action: string): Promise<boolean>;
}
// Simple note-taking app forced to implement everything
class SimpleNote implements Document {
private content: string = '';
getContent(): string { return this.content; }
setContent(content: string): void { this.content = content; }
appendContent(content: string): void { this.content += content; }
// Forced to implement methods it doesn't need
async save(): Promise<void> { throw new Error('Not supported'); }
async load(id: string): Promise<void> { throw new Error('Not supported'); }
async delete(): Promise<void> { throw new Error('Not supported'); }
async exportToPdf(): Promise<Buffer> { throw new Error('Not supported'); }
async exportToWord(): Promise<Buffer> { throw new Error('Not supported'); }
async exportToHtml(): Promise<string> { throw new Error('Not supported'); }
async share(userId: string): Promise<void> { throw new Error('Not supported'); }
async getCollaborators(): Promise<User[]> { throw new Error('Not supported'); }
async addComment(comment: Comment): Promise<void> { throw new Error('Not supported'); }
async getComments(): Promise<Comment[]> { throw new Error('Not supported'); }
async createVersion(): Promise<Version> { throw new Error('Not supported'); }
async getVersionHistory(): Promise<Version[]> { throw new Error('Not supported'); }
async revertToVersion(versionId: string): Promise<void> { throw new Error('Not supported'); }
async setPermissions(permissions: Permissions): Promise<void> { throw new Error('Not supported'); }
async getPermissions(): Promise<Permissions> { throw new Error('Not supported'); }
async checkPermission(userId: string, action: string): Promise<boolean> { throw new Error('Not supported'); }
}
```
## Good Example
```typescript
// Correct approach: Small, cohesive interfaces
// Core content interface - the essence of a document
interface DocumentContent {
getContent(): string;
setContent(content: string): void;
}
// Extended content operations
interface EditableContent extends DocumentContent {
appendContent(content: string): void;
insertContent(position: number, content: string): void;
deleteRange(start: number, end: number): void;
}
// Persistence operations
interface Persistable {
save(): Promise<void>;
load(id: string): Promise<void>;
}
interface Deletable {
delete(): Promise<void>;
}
// Export capabilities
interface PdfExportable {
exportToPdf(): Promise<Buffer>;
}
interface WordExportable {
exportToWord(): Promise<Buffer>;
}
interface HtmlExportable {
exportToHtml(): Promise<string>;
}
// Combine export interfaces when needed
interface FullyExportable extends PdfExportable, WordExportable, HtmlExportable {}
// Collaboration interfaces
interface Shareable {
share(userId: string, permission: Permission): Promise<void>;
unshare(userId: string): Promise<void>;
getCollaborators(): Promise<Collaborator[]>;
}
interface Commentable {
addComment(comment: Comment): Promise<void>;
removeComment(commentId: string): Promise<void>;
getComments(): Promise<Comment[]>;
}
// Version control
interface Versionable {
createVersion(message?: string): Promise<Version>;
getVersionHistory(): Promise<Version[]>;
revertToVersion(versionId: string): Promise<void>;
}
// Permissions
interface PermissionControlled {
setPermissions(permissions: Permissions): Promise<void>;
getPermissions(): Promise<Permissions>;
checkPermission(userId: string, action: string): Promise<boolean>;
}
// Simple note only implements what it needs
class SimpleNote implements DocumentContent {
private content: string = '';
getContent(): string {
return this.content;
}
setContent(content: string): void {
this.content = content;
}
}
// Persistent note adds storage
class PersistentNote implements EditableContent, Persistable {
private content: string = '';
private id: string | null = null;
constructor(private storage: StorageService) {}
getContent(): string { return this.content; }
setContent(content: string): void { this.content = content; }
appendContent(content: string): void { this.content += content; }
insertContent(position: number, content: string): void {
this.content = this.content.slice(0, position) + content + this.content.slice(position);
}
deleteRange(start: number, end: number): void {
this.content = this.content.slice(0, start) + this.content.slice(end);
}
async save(): Promise<void> {
this.id = await this.storage.save(this.content);
}
async load(id: string): Promise<void> {
this.content = await this.storage.load(id);
this.id = id;
}
}
// Full-featured collaborative document
class CollaborativeDocument implements
EditableContent,
Persistable,
Deletable,
FullyExportable,
Shareable,
Commentable,
Versionable,
PermissionControlled
{
constructor(
private storage: StorageService,
private exportService: ExportService,
private collaborationService: CollaborationService,
private versionService: VersionService,
private permissionService: PermissionService
) {}
// Implement all methods with actual functionality
// Each service handles its domain
getContent(): string { /* ... */ }
setContent(content: string): void { /* ... */ }
appendContent(content: string): void { /* ... */ }
insertContent(position: number, content: string): void { /* ... */ }
deleteRange(start: number, end: number): void { /* ... */ }
async save(): Promise<void> { /* delegate to storage */ }
async load(id: string): Promise<void> { /* delegate to storage */ }
async delete(): Promise<void> { /* delegate to storage */ }
async exportToPdf(): Promise<Buffer> { return this.exportService.toPdf(this); }
async exportToWord(): Promise<Buffer> { return this.exportService.toWord(this); }
async exportToHtml(): Promise<string> { return this.exportService.toHtml(this); }
async share(userId: string, permission: Permission): Promise<void> { /* ... */ }
async unshare(userId: string): Promise<void> { /* ... */ }
async getCollaborators(): Promise<Collaborator[]> { /* ... */ }
async addComment(comment: Comment): Promise<void> { /* ... */ }
async removeComment(commentId: string): Promise<void> { /* ... */ }
async getComments(): Promise<Comment[]> { /* ... */ }
async createVersion(message?: string): Promise<Version> { /* ... */ }
async getVersionHistory(): Promise<Version[]> { /* ... */ }
async revertToVersion(versionId: string): Promise<void> { /* ... */ }
async setPermissions(permissions: Permissions): Promise<void> { /* ... */ }
async getPermissions(): Promise<Permissions> { /* ... */ }
async checkPermission(userId: string, action: string): Promise<boolean> { /* ... */ }
}
// Functions can accept only the interfaces they need
function renderDocument(doc: DocumentContent): void {
console.log(doc.getContent());
}
async function exportToPdf(doc: PdfExportable): Promise<void> {
const pdf = await doc.exportToPdf();
// Send pdf...
}
function canUserEdit(doc: PermissionControlled, userId: string): Promise<boolean> {
return doc.checkPermission(userId, 'edit');
}
// All document types work where their interfaces are expected
const simpleNote = new SimpleNote();
renderDocument(simpleNote); // Works!
const collabDoc = new CollaborativeDocument(/* ... */);
renderDocument(collabDoc); // Works!
await exportToPdf(collabDoc); // Works!
await canUserEdit(collabDoc, 'user123'); // Works!
```
## Why
1. **Cohesion**: Each interface groups related methods. `Commentable` is about comments, `Versionable` about versions.
2. **Flexibility**: Classes implement only the interfaces that match their capabilities.
3. **Composability**: Complex types are built by combining simple interfaces: `EditableContent & Persistable & Shareable`.
4. **No Empty Implementations**: No need for `throw new Error('Not supported')` - just don't implement the interface.
5. **Clear Capabilities**: Looking at a class's implemented interfaces tells you exactly what it can do.
6. **Easier Evolution**: Add new capabilities by creating new interfaces without modifying existing ones.
7. **Better Typing**: Functions declare exactly what they need. `exportToPdf(doc: PdfExportable)` is self-documenting.

View File

@@ -0,0 +1,203 @@
---
id: solid-lsp-contracts
title: SOLID - Liskov Substitution (Contracts)
category: solid-principles
priority: critical
tags: [SOLID, LSP, liskov-substitution, contracts]
related: [solid-lsp-preconditions, solid-ocp-abstraction, core-composition]
---
# Liskov Substitution Principle - Contracts
Subtypes must be substitutable for their base types without altering the correctness of the program. Derived classes must honor the contracts established by their base classes.
## Bad Example
```typescript
// Anti-pattern: Subclass violates the contract of the base class
class Rectangle {
protected _width: number;
protected _height: number;
constructor(width: number, height: number) {
this._width = width;
this._height = height;
}
get width(): number {
return this._width;
}
set width(value: number) {
this._width = value;
}
get height(): number {
return this._height;
}
set height(value: number) {
this._height = value;
}
getArea(): number {
return this._width * this._height;
}
}
// Square violates LSP - it changes the behavior of setters
class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}
// Violates LSP: setter has different behavior than parent
set width(value: number) {
this._width = value;
this._height = value; // Unexpected side effect!
}
set height(value: number) {
this._width = value; // Unexpected side effect!
this._height = value;
}
}
// This code works with Rectangle but breaks with Square
function resizeRectangle(rect: Rectangle): void {
rect.width = 10;
rect.height = 5;
// Expects area to be 50, but Square gives 25!
console.assert(rect.getArea() === 50, 'Area should be 50');
}
const rectangle = new Rectangle(4, 4);
resizeRectangle(rectangle); // Works: area is 50
const square = new Square(4);
resizeRectangle(square); // Fails: area is 25, not 50
```
## Good Example
```typescript
// Correct approach: Use composition and proper abstractions
// Define what shapes can do
interface Shape {
getArea(): number;
getPerimeter(): number;
}
// Define what resizable shapes can do
interface ResizableShape extends Shape {
scale(factor: number): void;
}
// Immutable rectangle - no setters that could be violated
class Rectangle implements Shape {
constructor(
readonly width: number,
readonly height: number
) {
if (width <= 0 || height <= 0) {
throw new Error('Dimensions must be positive');
}
}
getArea(): number {
return this.width * this.height;
}
getPerimeter(): number {
return 2 * (this.width + this.height);
}
// Return new instance instead of mutating
withWidth(width: number): Rectangle {
return new Rectangle(width, this.height);
}
withHeight(height: number): Rectangle {
return new Rectangle(this.width, height);
}
scale(factor: number): Rectangle {
return new Rectangle(this.width * factor, this.height * factor);
}
}
// Square is its own shape, not a subtype of Rectangle
class Square implements Shape {
constructor(readonly side: number) {
if (side <= 0) {
throw new Error('Side must be positive');
}
}
getArea(): number {
return this.side * this.side;
}
getPerimeter(): number {
return 4 * this.side;
}
withSide(side: number): Square {
return new Square(side);
}
scale(factor: number): Square {
return new Square(this.side * factor);
}
}
// Functions work with the Shape interface
function printShapeInfo(shape: Shape): void {
console.log(`Area: ${shape.getArea()}`);
console.log(`Perimeter: ${shape.getPerimeter()}`);
}
// This works correctly with both Rectangle and Square
const rect = new Rectangle(10, 5);
printShapeInfo(rect); // Area: 50, Perimeter: 30
const square = new Square(5);
printShapeInfo(square); // Area: 25, Perimeter: 20
// For operations specific to rectangles, use Rectangle type
function createBanner(width: number, height: number): Rectangle {
return new Rectangle(width, height);
}
// For operations that work with any shape, use Shape interface
function calculateTotalArea(shapes: Shape[]): number {
return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}
const shapes: Shape[] = [
new Rectangle(10, 5),
new Square(4),
new Rectangle(3, 7)
];
console.log(calculateTotalArea(shapes)); // 50 + 16 + 21 = 87
```
## Why
1. **Predictable Behavior**: Code using the base type works correctly with any subtype. No surprises.
2. **Safe Polymorphism**: You can pass any `Shape` to functions expecting `Shape` without checking the concrete type.
3. **Contract Honoring**: Each class fully honors its interface contract - `getArea()` always returns the correct area.
4. **Immutability Benefits**: By making shapes immutable, we avoid the setter problem entirely.
5. **Proper Modeling**: Square and Rectangle are separate concepts that happen to share behavior (Shape), not a parent-child relationship.
6. **Easier Testing**: Tests for `Shape` work for all implementations. No special cases needed.
7. **Design Clarity**: The inheritance hierarchy reflects true "is-a" relationships, not just code reuse.

View File

@@ -0,0 +1,256 @@
---
id: solid-lsp-preconditions
title: SOLID - Liskov Substitution (Preconditions)
category: solid-principles
priority: critical
tags: [SOLID, LSP, liskov-substitution, preconditions, postconditions]
related: [solid-lsp-contracts, core-fail-fast, solid-ocp-abstraction]
---
# Liskov Substitution Principle - Preconditions and Postconditions
Subtypes cannot strengthen preconditions (require more) or weaken postconditions (guarantee less) compared to their base types.
## Bad Example
```typescript
// Anti-pattern: Subclass strengthens preconditions and weakens postconditions
interface PaymentProcessor {
// Contract: accepts any amount > 0, returns transaction ID
processPayment(amount: number): Promise<string>;
}
class StandardPaymentProcessor implements PaymentProcessor {
async processPayment(amount: number): Promise<string> {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
// Process payment and return transaction ID
return `TXN-${Date.now()}`;
}
}
// Violates LSP: Strengthens preconditions
class PremiumPaymentProcessor implements PaymentProcessor {
private readonly minimumAmount = 100; // Stronger precondition!
private readonly maximumAmount = 10000; // Stronger precondition!
async processPayment(amount: number): Promise<string> {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
// Additional restrictions not in base contract
if (amount < this.minimumAmount) {
throw new Error(`Minimum amount is ${this.minimumAmount}`); // Violates LSP!
}
if (amount > this.maximumAmount) {
throw new Error(`Maximum amount is ${this.maximumAmount}`); // Violates LSP!
}
return `PREM-TXN-${Date.now()}`;
}
}
// Violates LSP: Weakens postconditions
class UnreliablePaymentProcessor implements PaymentProcessor {
async processPayment(amount: number): Promise<string> {
if (amount <= 0) {
throw new Error('Amount must be positive');
}
// Sometimes returns null instead of transaction ID - weaker postcondition!
if (Math.random() > 0.5) {
return null as any; // Violates the guarantee of returning a string
}
return `TXN-${Date.now()}`;
}
}
// Client code breaks when substituting implementations
async function checkout(processor: PaymentProcessor, amount: number): Promise<void> {
const txnId = await processor.processPayment(amount);
// Assumes txnId is always a valid string (as per contract)
console.log(`Payment complete: ${txnId.toUpperCase()}`); // Crashes with null!
}
// Works with StandardPaymentProcessor
await checkout(new StandardPaymentProcessor(), 50);
// Fails with PremiumPaymentProcessor - amount too low
await checkout(new PremiumPaymentProcessor(), 50); // Error: Minimum amount is 100
// Fails with UnreliablePaymentProcessor - null txnId
await checkout(new UnreliablePaymentProcessor(), 50); // Error: Cannot read toUpperCase of null
```
## Good Example
```typescript
// Correct approach: Subtypes honor preconditions and postconditions
// Clear contract with documented preconditions and postconditions
interface PaymentProcessor {
/**
* Process a payment transaction.
*
* @precondition amount > 0
* @postcondition returns a non-empty transaction ID string
* @throws PaymentError if payment fails (not for invalid preconditions)
*/
processPayment(amount: number): Promise<PaymentResult>;
/**
* Check if this processor can handle the given amount.
* Allows clients to check before attempting payment.
*/
canProcess(amount: number): boolean;
/**
* Get the constraints of this processor.
*/
getConstraints(): ProcessorConstraints;
}
interface PaymentResult {
readonly transactionId: string;
readonly processedAt: Date;
readonly amount: number;
readonly status: 'success' | 'pending';
}
interface ProcessorConstraints {
readonly minAmount: number;
readonly maxAmount: number;
readonly supportedCurrencies: string[];
}
// Base implementation with standard constraints
class StandardPaymentProcessor implements PaymentProcessor {
private readonly constraints: ProcessorConstraints = {
minAmount: 0.01,
maxAmount: Infinity,
supportedCurrencies: ['USD', 'EUR', 'GBP']
};
getConstraints(): ProcessorConstraints {
return this.constraints;
}
canProcess(amount: number): boolean {
return amount >= this.constraints.minAmount &&
amount <= this.constraints.maxAmount;
}
async processPayment(amount: number): Promise<PaymentResult> {
// Precondition check (same as interface contract)
if (amount <= 0) {
throw new InvalidAmountError('Amount must be positive');
}
// Implementation
const transactionId = `TXN-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
// Postcondition: always returns valid PaymentResult
return {
transactionId,
processedAt: new Date(),
amount,
status: 'success'
};
}
}
// Premium processor with different constraints but same contract behavior
class PremiumPaymentProcessor implements PaymentProcessor {
private readonly constraints: ProcessorConstraints = {
minAmount: 100,
maxAmount: 100000,
supportedCurrencies: ['USD', 'EUR', 'GBP', 'JPY', 'CHF']
};
getConstraints(): ProcessorConstraints {
return this.constraints;
}
// Clients can check constraints before calling processPayment
canProcess(amount: number): boolean {
return amount >= this.constraints.minAmount &&
amount <= this.constraints.maxAmount;
}
async processPayment(amount: number): Promise<PaymentResult> {
// Same precondition as interface - not strengthened
if (amount <= 0) {
throw new InvalidAmountError('Amount must be positive');
}
// Business logic can still reject, but with proper error handling
if (!this.canProcess(amount)) {
throw new PaymentError(
`Amount ${amount} outside processor range [${this.constraints.minAmount}, ${this.constraints.maxAmount}]`
);
}
// Premium processing logic
const transactionId = `PREM-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
// Postcondition: always returns valid PaymentResult (same guarantee)
return {
transactionId,
processedAt: new Date(),
amount,
status: 'success'
};
}
}
// Factory that selects appropriate processor
class PaymentProcessorFactory {
private processors: PaymentProcessor[] = [
new StandardPaymentProcessor(),
new PremiumPaymentProcessor()
];
getProcessorFor(amount: number): PaymentProcessor {
const suitable = this.processors.find(p => p.canProcess(amount));
if (!suitable) {
throw new NoSuitableProcessorError(amount);
}
return suitable;
}
}
// Client code works correctly with any processor
async function checkout(amount: number, factory: PaymentProcessorFactory): Promise<void> {
const processor = factory.getProcessorFor(amount);
const result = await processor.processPayment(amount);
// Postcondition guarantees result is valid
console.log(`Payment complete: ${result.transactionId}`);
console.log(`Processed at: ${result.processedAt.toISOString()}`);
}
// Usage
const factory = new PaymentProcessorFactory();
await checkout(50, factory); // Uses StandardPaymentProcessor
await checkout(500, factory); // Uses PremiumPaymentProcessor
await checkout(5000, factory); // Uses PremiumPaymentProcessor
```
## Why
1. **Substitutability**: Any `PaymentProcessor` can be used interchangeably where `PaymentProcessor` is expected.
2. **Client Safety**: Clients can rely on the contract. If preconditions are met, postconditions are guaranteed.
3. **Explicit Constraints**: Instead of silently strengthening preconditions, processors expose their constraints through `getConstraints()` and `canProcess()`.
4. **Proper Error Handling**: Constraint violations throw appropriate errors that clients can handle, rather than unexpected failures.
5. **Factory Pattern**: The factory selects the appropriate processor, keeping constraint logic out of client code.
6. **Design by Contract**: Clear documentation of preconditions and postconditions makes the contract explicit.
7. **Defensive Programming**: Clients can check `canProcess()` before attempting payment, avoiding errors entirely.

View File

@@ -0,0 +1,265 @@
---
id: solid-ocp-abstraction
title: SOLID - Open/Closed (Abstraction)
category: solid-principles
priority: critical
tags: [SOLID, OCP, open-closed, abstraction]
related: [solid-ocp-extension, solid-dip-abstractions, pattern-repository]
---
# Open/Closed Principle - Abstraction
Use abstractions (interfaces and abstract classes) to define stable contracts that allow new implementations without modifying existing code.
## Bad Example
```typescript
// Anti-pattern: Concrete dependencies that require modification for new requirements
class ReportGenerator {
generateReport(data: SalesData[], format: string): string {
let report = '';
// Calculate totals
const totalSales = data.reduce((sum, d) => sum + d.amount, 0);
const averageSale = totalSales / data.length;
if (format === 'html') {
report = `
<html>
<body>
<h1>Sales Report</h1>
<p>Total Sales: $${totalSales}</p>
<p>Average Sale: $${averageSale.toFixed(2)}</p>
<table>
${data.map(d => `<tr><td>${d.date}</td><td>$${d.amount}</td></tr>`).join('')}
</table>
</body>
</html>
`;
} else if (format === 'pdf') {
// PDF generation logic mixed in
const pdf = new PDFDocument();
pdf.text('Sales Report');
pdf.text(`Total: $${totalSales}`);
// ... more PDF logic
report = pdf.output();
} else if (format === 'csv') {
report = 'Date,Amount\n';
report += data.map(d => `${d.date},${d.amount}`).join('\n');
} else if (format === 'excel') {
// Excel generation logic
// Adding new format requires modifying this class
}
return report;
}
}
// Problems:
// 1. Adding JSON format requires modifying ReportGenerator
// 2. All format logic is tightly coupled
// 3. Testing is difficult
// 4. No way to extend without modification
```
## Good Example
```typescript
// Correct approach: Abstractions enable extension without modification
// Stable abstraction for report data
interface ReportData {
readonly title: string;
readonly generatedAt: Date;
readonly summary: ReportSummary;
readonly items: ReportItem[];
}
interface ReportSummary {
readonly totalSales: number;
readonly averageSale: number;
readonly itemCount: number;
}
interface ReportItem {
readonly date: string;
readonly amount: number;
readonly description?: string;
}
// Stable abstraction for formatters
interface ReportFormatter {
readonly format: string;
readonly mimeType: string;
render(data: ReportData): string | Buffer | Promise<string | Buffer>;
}
// Stable abstraction for data sources
interface ReportDataSource<T> {
fetch(criteria: ReportCriteria): Promise<T[]>;
transform(rawData: T[]): ReportData;
}
// Report generator depends only on abstractions
class ReportGenerator {
constructor(
private formatters: Map<string, ReportFormatter> = new Map()
) {}
registerFormatter(formatter: ReportFormatter): void {
this.formatters.set(formatter.format, formatter);
}
async generate<T>(
dataSource: ReportDataSource<T>,
criteria: ReportCriteria,
format: string
): Promise<GeneratedReport> {
const formatter = this.formatters.get(format);
if (!formatter) {
throw new UnsupportedFormatError(format, this.getSupportedFormats());
}
const rawData = await dataSource.fetch(criteria);
const reportData = dataSource.transform(rawData);
const content = await Promise.resolve(formatter.render(reportData));
return {
content,
mimeType: formatter.mimeType,
format,
generatedAt: new Date()
};
}
getSupportedFormats(): string[] {
return Array.from(this.formatters.keys());
}
}
// Concrete formatters - each can be added without modifying ReportGenerator
class HtmlReportFormatter implements ReportFormatter {
readonly format = 'html';
readonly mimeType = 'text/html';
render(data: ReportData): string {
return `
<!DOCTYPE html>
<html>
<head><title>${data.title}</title></head>
<body>
<h1>${data.title}</h1>
<p>Generated: ${data.generatedAt.toISOString()}</p>
<section class="summary">
<p>Total Sales: $${data.summary.totalSales.toFixed(2)}</p>
<p>Average Sale: $${data.summary.averageSale.toFixed(2)}</p>
</section>
<table>
<thead><tr><th>Date</th><th>Amount</th></tr></thead>
<tbody>
${data.items.map(item =>
`<tr><td>${item.date}</td><td>$${item.amount}</td></tr>`
).join('')}
</tbody>
</table>
</body>
</html>
`;
}
}
class CsvReportFormatter implements ReportFormatter {
readonly format = 'csv';
readonly mimeType = 'text/csv';
render(data: ReportData): string {
const header = 'Date,Amount,Description';
const rows = data.items.map(item =>
`${item.date},${item.amount},"${item.description || ''}"`
);
return [header, ...rows].join('\n');
}
}
class JsonReportFormatter implements ReportFormatter {
readonly format = 'json';
readonly mimeType = 'application/json';
render(data: ReportData): string {
return JSON.stringify(data, null, 2);
}
}
// Adding Excel format - no modification to existing code
class ExcelReportFormatter implements ReportFormatter {
readonly format = 'xlsx';
readonly mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
render(data: ReportData): Buffer {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(data.title);
sheet.addRow(['Date', 'Amount', 'Description']);
data.items.forEach(item => {
sheet.addRow([item.date, item.amount, item.description || '']);
});
return workbook.xlsx.writeBuffer();
}
}
// Concrete data source - can create new sources without modification
class SalesReportDataSource implements ReportDataSource<SalesData> {
constructor(private salesRepository: SalesRepository) {}
async fetch(criteria: ReportCriteria): Promise<SalesData[]> {
return this.salesRepository.findByCriteria(criteria);
}
transform(rawData: SalesData[]): ReportData {
const totalSales = rawData.reduce((sum, d) => sum + d.amount, 0);
return {
title: 'Sales Report',
generatedAt: new Date(),
summary: {
totalSales,
averageSale: rawData.length > 0 ? totalSales / rawData.length : 0,
itemCount: rawData.length
},
items: rawData.map(d => ({
date: d.date,
amount: d.amount,
description: d.productName
}))
};
}
}
// Usage
const generator = new ReportGenerator();
generator.registerFormatter(new HtmlReportFormatter());
generator.registerFormatter(new CsvReportFormatter());
generator.registerFormatter(new JsonReportFormatter());
generator.registerFormatter(new ExcelReportFormatter());
const salesDataSource = new SalesReportDataSource(salesRepository);
const report = await generator.generate(salesDataSource, criteria, 'xlsx');
```
## Why
1. **Stable Core**: The `ReportGenerator` class and interfaces form a stable core that rarely changes.
2. **Independent Development**: Teams can develop new formatters or data sources independently.
3. **Composition Over Inheritance**: New functionality is added through composition (registering new implementations) rather than inheritance hierarchies.
4. **Testing**: Each component can be tested in isolation with mock implementations of interfaces.
5. **Flexibility**: The same generator works with any combination of data sources and formatters.
6. **Dependency Inversion**: High-level modules (ReportGenerator) don't depend on low-level modules (specific formatters), both depend on abstractions.
7. **Plugin System**: Natural foundation for a plugin architecture where third parties can add formatters.

View File

@@ -0,0 +1,205 @@
---
id: solid-ocp-extension
title: SOLID - Open/Closed Principle (Extension)
category: solid-principles
priority: critical
tags: [SOLID, OCP, open-closed, extensibility, design-patterns]
related: [solid-ocp-abstraction, pattern-repository, solid-dip-abstractions]
---
# Open/Closed Principle - Extension
Software entities should be open for extension but closed for modification. Add new functionality by adding new code, not by changing existing code.
## Bad Example
```typescript
// Anti-pattern: Modifying existing code to add new functionality
class PaymentProcessor {
processPayment(payment: Payment): PaymentResult {
switch (payment.method) {
case 'credit_card':
return this.processCreditCard(payment);
case 'paypal':
return this.processPayPal(payment);
case 'stripe':
return this.processStripe(payment);
// Every new payment method requires modifying this class
case 'apple_pay':
return this.processApplePay(payment);
case 'google_pay':
return this.processGooglePay(payment);
case 'crypto':
return this.processCrypto(payment);
default:
throw new Error(`Unknown payment method: ${payment.method}`);
}
}
private processCreditCard(payment: Payment): PaymentResult {
// Credit card logic
}
private processPayPal(payment: Payment): PaymentResult {
// PayPal logic
}
private processStripe(payment: Payment): PaymentResult {
// Stripe logic
}
private processApplePay(payment: Payment): PaymentResult {
// Apple Pay logic
}
private processGooglePay(payment: Payment): PaymentResult {
// Google Pay logic
}
private processCrypto(payment: Payment): PaymentResult {
// Crypto logic
}
}
// Adding a new payment method requires:
// 1. Adding a new case to the switch statement
// 2. Adding a new private method
// 3. Testing the entire class again
```
## Good Example
```typescript
// Correct approach: Open for extension, closed for modification
// Define the contract for payment handlers
interface PaymentHandler {
readonly methodType: string;
canHandle(payment: Payment): boolean;
process(payment: Payment): Promise<PaymentResult>;
validate(payment: Payment): ValidationResult;
}
// Payment processor that never needs modification
class PaymentProcessor {
private handlers: Map<string, PaymentHandler> = new Map();
registerHandler(handler: PaymentHandler): void {
this.handlers.set(handler.methodType, handler);
}
async processPayment(payment: Payment): Promise<PaymentResult> {
const handler = this.handlers.get(payment.method);
if (!handler) {
throw new UnsupportedPaymentMethodError(payment.method);
}
const validation = handler.validate(payment);
if (!validation.isValid) {
throw new PaymentValidationError(validation.errors);
}
return handler.process(payment);
}
getSupportedMethods(): string[] {
return Array.from(this.handlers.keys());
}
}
// Each payment method is a separate class that can be added without modifying PaymentProcessor
class CreditCardPaymentHandler implements PaymentHandler {
readonly methodType = 'credit_card';
canHandle(payment: Payment): boolean {
return payment.method === this.methodType;
}
validate(payment: Payment): ValidationResult {
const errors: string[] = [];
if (!payment.cardNumber || payment.cardNumber.length !== 16) {
errors.push('Invalid card number');
}
if (!payment.cvv || payment.cvv.length !== 3) {
errors.push('Invalid CVV');
}
return { isValid: errors.length === 0, errors };
}
async process(payment: Payment): Promise<PaymentResult> {
// Credit card processing logic
return { success: true, transactionId: generateId() };
}
}
class PayPalPaymentHandler implements PaymentHandler {
readonly methodType = 'paypal';
canHandle(payment: Payment): boolean {
return payment.method === this.methodType;
}
validate(payment: Payment): ValidationResult {
const errors: string[] = [];
if (!payment.paypalEmail) {
errors.push('PayPal email required');
}
return { isValid: errors.length === 0, errors };
}
async process(payment: Payment): Promise<PaymentResult> {
// PayPal processing logic
return { success: true, transactionId: generateId() };
}
}
// Adding a new payment method - no modification to existing code!
class CryptoPaymentHandler implements PaymentHandler {
readonly methodType = 'crypto';
canHandle(payment: Payment): boolean {
return payment.method === this.methodType;
}
validate(payment: Payment): ValidationResult {
const errors: string[] = [];
if (!payment.walletAddress) {
errors.push('Wallet address required');
}
if (!payment.cryptoCurrency) {
errors.push('Cryptocurrency type required');
}
return { isValid: errors.length === 0, errors };
}
async process(payment: Payment): Promise<PaymentResult> {
// Crypto processing logic
return { success: true, transactionId: generateId() };
}
}
// Application setup - register handlers
const processor = new PaymentProcessor();
processor.registerHandler(new CreditCardPaymentHandler());
processor.registerHandler(new PayPalPaymentHandler());
processor.registerHandler(new CryptoPaymentHandler());
// Add new payment methods by simply registering new handlers
```
## Why
1. **Stability**: The `PaymentProcessor` class is stable and tested. Adding new payment methods doesn't risk breaking existing functionality.
2. **Team Scalability**: Different developers can work on different payment handlers simultaneously without merge conflicts.
3. **Plugin Architecture**: Payment handlers can be loaded dynamically, even from external packages or plugins.
4. **Testing Isolation**: Each handler is tested independently. New handlers don't require retesting old ones.
5. **Feature Flags**: New payment methods can be enabled/disabled by simply registering or not registering the handler.
6. **Compliance**: When regulations change for one payment method, only that handler needs updating.
7. **Rollback Safety**: If a new payment handler has issues, remove it without touching proven code.

View File

@@ -0,0 +1,149 @@
---
id: solid-srp-class
title: SOLID - Single Responsibility Principle (Class Level)
category: solid-principles
priority: critical
tags: [SOLID, SRP, single-responsibility, class-design]
related: [solid-srp-function, core-separation-concerns, solid-isp-interfaces]
---
# Single Responsibility Principle - Class Level
A class should have only one reason to change, meaning it should have only one job or responsibility.
## Bad Example
```typescript
// Anti-pattern: Class handles multiple responsibilities
class UserManager {
private db: Database;
private mailer: EmailService;
constructor() {
this.db = new Database();
this.mailer = new EmailService();
}
createUser(userData: UserData): User {
// Validation logic
if (!userData.email.includes('@')) {
throw new Error('Invalid email');
}
if (userData.password.length < 8) {
throw new Error('Password too short');
}
// Database logic
const user = this.db.insert('users', userData);
// Email logic
const htmlContent = `<h1>Welcome ${user.name}!</h1>`;
this.mailer.send(user.email, 'Welcome', htmlContent);
// Logging logic
console.log(`User created: ${user.id} at ${new Date()}`);
this.writeToFile(`/logs/users.log`, `Created: ${user.id}`);
return user;
}
private writeToFile(path: string, content: string): void {
// File writing implementation
}
}
```
## Good Example
```typescript
// Correct approach: Each class has a single responsibility
// Handles user validation only
class UserValidator {
validate(userData: UserData): ValidationResult {
const errors: string[] = [];
if (!userData.email.includes('@')) {
errors.push('Invalid email format');
}
if (userData.password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return { isValid: errors.length === 0, errors };
}
}
// Handles user persistence only
class UserRepository {
constructor(private db: Database) {}
save(userData: UserData): User {
return this.db.insert('users', userData);
}
findById(id: string): User | null {
return this.db.findOne('users', { id });
}
}
// Handles welcome email only
class WelcomeEmailService {
constructor(private mailer: EmailService) {}
sendWelcomeEmail(user: User): void {
const htmlContent = this.buildTemplate(user);
this.mailer.send(user.email, 'Welcome', htmlContent);
}
private buildTemplate(user: User): string {
return `<h1>Welcome ${user.name}!</h1>`;
}
}
// Handles user activity logging only
class UserActivityLogger {
constructor(private logger: Logger) {}
logCreation(user: User): void {
this.logger.info(`User created: ${user.id}`, { userId: user.id });
}
}
// Orchestrates the user creation process
class UserService {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private welcomeEmail: WelcomeEmailService,
private activityLogger: UserActivityLogger
) {}
createUser(userData: UserData): User {
const validation = this.validator.validate(userData);
if (!validation.isValid) {
throw new ValidationError(validation.errors);
}
const user = this.repository.save(userData);
this.welcomeEmail.sendWelcomeEmail(user);
this.activityLogger.logCreation(user);
return user;
}
}
```
## Why
1. **Easier Testing**: Each class can be unit tested in isolation without mocking unrelated dependencies.
2. **Reduced Coupling**: Changes to email templates don't affect database logic or validation rules.
3. **Better Reusability**: The `UserValidator` can be reused for profile updates, the `WelcomeEmailService` can be triggered from different flows.
4. **Clearer Ownership**: Teams can own specific classes without stepping on each other's work.
5. **Simpler Maintenance**: Bug in email formatting? Look only at `WelcomeEmailService`. Validation issue? Check `UserValidator`.
6. **Flexible Composition**: Easy to add features like async email sending or different logging strategies without touching core logic.

View File

@@ -0,0 +1,208 @@
---
id: solid-srp-function
title: SOLID - Single Responsibility Principle (Function Level)
category: solid-principles
priority: critical
tags: [SOLID, SRP, single-responsibility, function-design]
related: [solid-srp-class, core-dry-extraction, core-kiss-simplicity]
---
# Single Responsibility Principle - Function Level
A function should do one thing, do it well, and do it only. It should have a single, clear purpose that can be described without using conjunctions like "and" or "or".
## Bad Example
```typescript
// Anti-pattern: Function does multiple things
async function processOrder(orderId: string): Promise<void> {
// Fetch order
const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
// Validate order
if (!order) throw new Error('Order not found');
if (order.status !== 'pending') throw new Error('Order already processed');
if (order.items.length === 0) throw new Error('Order has no items');
// Calculate totals
let subtotal = 0;
for (const item of order.items) {
const product = await db.query('SELECT price FROM products WHERE id = ?', [item.productId]);
subtotal += product.price * item.quantity;
}
const tax = subtotal * 0.1;
const shipping = subtotal > 100 ? 0 : 10;
const total = subtotal + tax + shipping;
// Update inventory
for (const item of order.items) {
await db.query('UPDATE products SET stock = stock - ? WHERE id = ?',
[item.quantity, item.productId]);
}
// Process payment
const paymentResult = await stripe.charges.create({
amount: Math.round(total * 100),
currency: 'usd',
customer: order.customerId
});
// Update order status
await db.query('UPDATE orders SET status = ?, total = ?, payment_id = ? WHERE id = ?',
['completed', total, paymentResult.id, orderId]);
// Send confirmation email
const emailHtml = `<h1>Order Confirmed</h1><p>Total: $${total}</p>`;
await sendgrid.send({
to: order.customerEmail,
subject: 'Order Confirmation',
html: emailHtml
});
// Log analytics
await analytics.track('order_completed', { orderId, total, itemCount: order.items.length });
}
```
## Good Example
```typescript
// Correct approach: Each function has a single purpose
async function processOrder(orderId: string): Promise<ProcessedOrder> {
const order = await fetchAndValidateOrder(orderId);
const pricing = await calculateOrderPricing(order);
await reserveInventory(order.items);
try {
const payment = await processPayment(order.customerId, pricing.total);
const completedOrder = await finalizeOrder(order, pricing, payment);
await sendOrderConfirmation(completedOrder);
await trackOrderCompletion(completedOrder);
return completedOrder;
} catch (error) {
await releaseInventory(order.items);
throw error;
}
}
async function fetchAndValidateOrder(orderId: string): Promise<Order> {
const order = await orderRepository.findById(orderId);
if (!order) {
throw new OrderNotFoundError(orderId);
}
validateOrderStatus(order);
validateOrderItems(order);
return order;
}
function validateOrderStatus(order: Order): void {
if (order.status !== 'pending') {
throw new InvalidOrderStatusError(order.id, order.status);
}
}
function validateOrderItems(order: Order): void {
if (order.items.length === 0) {
throw new EmptyOrderError(order.id);
}
}
async function calculateOrderPricing(order: Order): Promise<OrderPricing> {
const subtotal = await calculateSubtotal(order.items);
const tax = calculateTax(subtotal);
const shipping = calculateShipping(subtotal);
return {
subtotal,
tax,
shipping,
total: subtotal + tax + shipping
};
}
async function calculateSubtotal(items: OrderItem[]): Promise<number> {
const itemTotals = await Promise.all(
items.map(item => calculateItemTotal(item))
);
return itemTotals.reduce((sum, total) => sum + total, 0);
}
async function calculateItemTotal(item: OrderItem): Promise<number> {
const product = await productRepository.findById(item.productId);
return product.price * item.quantity;
}
function calculateTax(subtotal: number): number {
const TAX_RATE = 0.1;
return subtotal * TAX_RATE;
}
function calculateShipping(subtotal: number): number {
const FREE_SHIPPING_THRESHOLD = 100;
const STANDARD_SHIPPING = 10;
return subtotal > FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING;
}
async function reserveInventory(items: OrderItem[]): Promise<void> {
await Promise.all(
items.map(item => inventoryService.reserve(item.productId, item.quantity))
);
}
async function releaseInventory(items: OrderItem[]): Promise<void> {
await Promise.all(
items.map(item => inventoryService.release(item.productId, item.quantity))
);
}
async function processPayment(customerId: string, amount: number): Promise<Payment> {
return paymentService.charge(customerId, amount);
}
async function finalizeOrder(
order: Order,
pricing: OrderPricing,
payment: Payment
): Promise<ProcessedOrder> {
return orderRepository.updateStatus(order.id, {
status: 'completed',
total: pricing.total,
paymentId: payment.id
});
}
async function sendOrderConfirmation(order: ProcessedOrder): Promise<void> {
await emailService.sendOrderConfirmation(order);
}
async function trackOrderCompletion(order: ProcessedOrder): Promise<void> {
await analyticsService.track('order_completed', {
orderId: order.id,
total: order.total,
itemCount: order.items.length
});
}
```
## Why
1. **Readability**: The main function reads like a story - fetch, validate, calculate, reserve, charge, finalize, notify.
2. **Testability**: Each function can be unit tested independently. Testing `calculateTax` doesn't require mocking a database.
3. **Reusability**: `calculateShipping` can be reused in a shipping estimate feature. `validateOrderStatus` can be used in other order operations.
4. **Debugging**: Stack traces point to specific functions. "Error in calculateSubtotal" is more helpful than "Error in processOrder at line 47".
5. **Modification**: Changing tax calculation only touches `calculateTax`. Adding a discount feature can be inserted cleanly between subtotal and tax.
6. **Error Handling**: Each function can have appropriate error handling. The main function can orchestrate rollback on failure.
7. **Documentation**: Function names serve as documentation. The code is self-explanatory without comments.

View File

@@ -0,0 +1,99 @@
---
name: clean-code
description: "This skill embodies the principles of \"Clean Code\" by Robert C. Martin (Uncle Bob). Use it to transform \"code that works\" into \"code that is clean.\""
risk: safe
source: "ClawForge (https://github.com/jackjin1997/ClawForge)"
date_added: "2026-02-27"
---
# Clean Code Skill
This skill embodies the principles of "Clean Code" by Robert C. Martin (Uncle Bob). Use it to transform "code that works" into "code that is clean."
## 🧠 Core Philosophy
> "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch
## When to Use
Use this skill when:
- **Writing new code**: To ensure high quality from the start.
- **Reviewing Pull Requests**: To provide constructive, principle-based feedback.
- **Refactoring legacy code**: To identify and remove code smells.
- **Improving team standards**: To align on industry-standard best practices.
## 1. Meaningful Names
- **Use Intention-Revealing Names**: `elapsedTimeInDays` instead of `d`.
- **Avoid Disinformation**: Don't use `accountList` if it's actually a `Map`.
- **Make Meaningful Distinctions**: Avoid `ProductData` vs `ProductInfo`.
- **Use Pronounceable/Searchable Names**: Avoid `genymdhms`.
- **Class Names**: Use nouns (`Customer`, `WikiPage`). Avoid `Manager`, `Data`.
- **Method Names**: Use verbs (`postPayment`, `deletePage`).
## 2. Functions
- **Small!**: Functions should be shorter than you think.
- **Do One Thing**: A function should do only one thing, and do it well.
- **One Level of Abstraction**: Don't mix high-level business logic with low-level details (like regex).
- **Descriptive Names**: `isPasswordValid` is better than `check`.
- **Arguments**: 0 is ideal, 1-2 is okay, 3+ requires a very strong justification.
- **No Side Effects**: Functions shouldn't secretly change global state.
## 3. Comments
- **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code.
- **Explain Yourself in Code**:
```python
# Check if employee is eligible for full benefits
if employee.flags & HOURLY and employee.age > 65:
```
vs
```python
if employee.isEligibleForFullBenefits():
```
- **Good Comments**: Legal, Informative (regex intent), Clarification (external libraries), TODOs.
- **Bad Comments**: Mumbling, Redundant, Misleading, Mandated, Noise, Position Markers.
## 4. Formatting
- **The Newspaper Metaphor**: High-level concepts at the top, details at the bottom.
- **Vertical Density**: Related lines should be close to each other.
- **Distance**: Variables should be declared near their usage.
- **Indentation**: Essential for structural readability.
## 5. Objects and Data Structures
- **Data Abstraction**: Hide the implementation behind interfaces.
- **The Law of Demeter**: A module should not know about the innards of the objects it manipulates. Avoid `a.getB().getC().doSomething()`.
- **Data Transfer Objects (DTO)**: Classes with public variables and no functions.
## 6. Error Handling
- **Use Exceptions instead of Return Codes**: Keeps logic clean.
- **Write Try-Catch-Finally First**: Defines the scope of the operation.
- **Don't Return Null**: It forces the caller to check for null every time.
- **Don't Pass Null**: Leads to `NullPointerException`.
## 7. Unit Tests
- **The Three Laws of TDD**:
1. Don't write production code until you have a failing unit test.
2. Don't write more of a unit test than is sufficient to fail.
3. Don't write more production code than is sufficient to pass the failing test.
- **F.I.R.S.T. Principles**: Fast, Independent, Repeatable, Self-Validating, Timely.
## 8. Classes
- **Small!**: Classes should have a single responsibility (SRP).
- **The Stepdown Rule**: We want the code to read like a top-down narrative.
## 9. Smells and Heuristics
- **Rigidity**: Hard to change.
- **Fragility**: Breaks in many places.
- **Immobility**: Hard to reuse.
- **Viscosity**: Hard to do the right thing.
- **Needless Complexity/Repetition**.
## 🛠️ Implementation Checklist
- [ ] Is this function smaller than 20 lines?
- [ ] Does this function do exactly one thing?
- [ ] Are all names searchable and intention-revealing?
- [ ] Have I avoided comments by making the code clearer?
- [ ] Am I passing too many arguments?
- [ ] Is there a failing test for this change?
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.

View File

@@ -0,0 +1,598 @@
---
name: hono
description: Use when building Hono web applications or when the user asks about Hono APIs, routing, middleware, JSX, validation, testing, or streaming. TRIGGER when code imports from 'hono' or 'hono/*', or user mentions Hono. Use Hono CLI to inspect and test the app.
---
# Hono Skill
Build Hono web applications. This skill provides inline API knowledge for AI. Use Hono CLI to inspect and test the app.
## Latest Documentation
For details beyond this inline reference, fetch the latest documentation from https://hono.dev. Get the index of doc pages from `https://hono.dev/llms.txt`, then fetch a page with the `Accept: text/markdown` header to receive it as Markdown:
```bash
curl -H "Accept: text/markdown" https://hono.dev/docs/helpers/cookie
```
## Hono CLI
Use [Hono CLI](https://github.com/honojs/cli) to inspect and test the app. Install it in the project, then let the CLI explain itself:
<!-- TODO at the 0.2 release: change @hono/cli@next to @hono/cli -->
```bash
npm install -D @hono/cli@next
npx hono agent-context
```
Follow the output. It explains every command (`routes`, `request`, `benchmark`, `optimize`, `ssg`), the JSON output contract, and the workflow.
Notes:
- `hono request` sends a request with `app.request()` — no server needed. Do not pass credentials directly in CLI arguments; use environment variables for sensitive values.
- For Cloudflare Workers bindings (KV, D1, R2, etc.), use `hono request /path --runtime workerd`. It starts the app with the wrangler config of the project, so the local bindings (`c.env`) are real. wrangler must be installed in the project.
- For several requests, or a flow that keeps state (POST, then use the returned id), use one `hono request --batch -` call. One JSON object per line; `save` a value and use it as `{{id}}` in later steps. The steps share one app instance:
```bash
npx hono request --batch - <<'EOF'
{"method":"POST","path":"/users","body":{"name":"Alice"},"save":{"id":".id"}}
{"path":"/users/{{id}}"}
EOF
```
---
## Hono API Reference
### App Constructor
```ts
import { Hono } from 'hono'
const app = new Hono()
// With TypeScript generics
type Env = {
Bindings: { DATABASE: D1Database; KV: KVNamespace }
Variables: { user: User }
}
const app = new Hono<Env>()
```
### Routing Methods
```ts
app.get('/path', handler)
app.post('/path', handler)
app.put('/path', handler)
app.delete('/path', handler)
app.patch('/path', handler)
app.options('/path', handler)
app.all('/path', handler) // all HTTP methods
app.on('PURGE', '/path', handler) // custom method
app.on(['PUT', 'DELETE'], '/path', handler) // multiple methods
```
### Routing Patterns
```ts
// Path parameters
app.get('/user/:name', (c) => {
const name = c.req.param('name')
return c.json({ name })
})
// Multiple params
app.get('/posts/:id/comments/:commentId', (c) => {
const { id, commentId } = c.req.param()
})
// Optional parameters
app.get('/api/animal/:type?', (c) => c.text('Animal!'))
// Wildcards
app.get('/wild/*/card', (c) => c.text('Wildcard'))
// Regexp constraints
app.get('/post/:date{[0-9]+}/:title{[a-z]+}', (c) => {
const { date, title } = c.req.param()
})
// Chained routes
app
.get('/endpoint', (c) => c.text('GET'))
.post((c) => c.text('POST'))
.delete((c) => c.text('DELETE'))
```
### Route Grouping
```ts
// Using route()
const api = new Hono()
api.get('/users', (c) => c.json([]))
const app = new Hono()
app.route('/api', api) // mounts at /api/users
// Using basePath()
const app = new Hono().basePath('/api')
app.get('/users', (c) => c.json([])) // GET /api/users
```
### Error Handling
```ts
app.notFound((c) => c.json({ message: 'Not Found' }, 404))
app.onError((err, c) => {
console.error(err)
return c.json({ message: 'Internal Server Error' }, 500)
})
```
---
## Context (c)
### Response Methods
```ts
c.text('Hello') // text/plain
c.json({ message: 'Hello' }) // application/json
c.html('<h1>Hello</h1>') // text/html
c.redirect('/new-path') // 302 redirect
c.redirect('/new-path', 301) // 301 redirect
c.body('raw body', 200, headers) // raw response
c.notFound() // 404 response
```
### Headers & Status
```ts
c.status(201)
c.header('X-Custom', 'value')
c.header('Cache-Control', 'no-store')
```
### Variables (request-scoped data)
```ts
// In middleware
c.set('user', { id: 1, name: 'Alice' })
// In handler
const user = c.get('user')
// or
const user = c.var.user
```
### Environment (Cloudflare Workers)
```ts
const value = await c.env.KV.get('key')
const db = c.env.DATABASE
c.executionCtx.waitUntil(promise)
```
### Renderer
```ts
app.use(async (c, next) => {
c.setRenderer((content) =>
c.html(
<html><body>{content}</body></html>
)
)
await next()
})
app.get('/', (c) => c.render(<h1>Hello</h1>))
```
---
## HonoRequest (c.req)
```ts
c.req.param('id') // path parameter
c.req.param() // all path params as object
c.req.query('page') // query string parameter
c.req.query() // all query params as object
c.req.queries('tags') // multiple values: ?tags=A&tags=B → ['A', 'B']
c.req.header('Authorization') // request header
c.req.header() // all headers (keys are lowercase)
// Body parsing
await c.req.json() // parse JSON body
await c.req.text() // parse text body
await c.req.formData() // parse as FormData
await c.req.parseBody() // parse multipart/form-data or urlencoded
await c.req.arrayBuffer() // parse as ArrayBuffer
await c.req.blob() // parse as Blob
// Validated data (used with validator middleware)
c.req.valid('json')
c.req.valid('query')
c.req.valid('form')
c.req.valid('param')
// Properties
c.req.url // full URL string
c.req.path // pathname
c.req.method // HTTP method
c.req.raw // underlying Request object
```
---
## Middleware
### Using Built-in Middleware
```ts
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { basicAuth } from 'hono/basic-auth'
import { prettyJSON } from 'hono/pretty-json'
import { secureHeaders } from 'hono/secure-headers'
import { etag } from 'hono/etag'
import { compress } from 'hono/compress'
import { poweredBy } from 'hono/powered-by'
import { timing } from 'hono/timing'
import { cache } from 'hono/cache'
import { bearerAuth } from 'hono/bearer-auth'
import { jwt } from 'hono/jwt'
import { jwk } from 'hono/jwk'
import { csrf } from 'hono/csrf'
import { ipRestriction } from 'hono/ip-restriction'
import { bodyLimit } from 'hono/body-limit'
import { timeout } from 'hono/timeout'
import { requestId } from 'hono/request-id'
import { methodOverride } from 'hono/method-override'
import { methodNotAllowed } from 'hono/method-not-allowed'
import { languageDetector } from 'hono/language'
import { some, every, except } from 'hono/combine'
import { contextStorage, getContext } from 'hono/context-storage'
import { trailingSlash, trimTrailingSlash } from 'hono/trailing-slash'
// Registration
app.use(logger()) // all routes
app.use('/api/*', cors()) // specific path
app.post('/api/*', basicAuth({ username: 'admin', password: 'secret' }))
```
### Custom Middleware
```ts
// Inline
app.use(async (c, next) => {
const start = Date.now()
await next()
const elapsed = Date.now() - start
c.res.headers.set('X-Response-Time', `${elapsed}ms`)
})
// Reusable with createMiddleware
import { createMiddleware } from 'hono/factory'
const auth = createMiddleware(async (c, next) => {
const token = c.req.header('Authorization')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
await next()
})
app.use('/api/*', auth)
```
### Middleware Execution Order
Middleware executes in registration order. `await next()` calls the next middleware/handler, and code after `next()` runs on the way back:
```
Request → mw1 before → mw2 before → handler → mw2 after → mw1 after → Response
```
```ts
app.use(async (c, next) => {
// before handler
await next()
// after handler
})
```
---
## Validation
Validation targets: `json`, `form`, `query`, `header`, `param`, `cookie`.
### Zod Validator
```ts
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1),
body: z.string()
})
app.post('/posts', zValidator('json', schema), (c) => {
const data = c.req.valid('json') // fully typed
return c.json(data, 201)
})
```
### Valibot / Standard Schema Validator
```ts
import { sValidator } from '@hono/standard-validator'
import * as v from 'valibot'
const schema = v.object({ name: v.string(), age: v.number() })
app.post('/users', sValidator('json', schema), (c) => {
const data = c.req.valid('json')
return c.json(data, 201)
})
```
---
## JSX
### Setup
In `tsconfig.json`:
```json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}
```
Or use pragma: `/** @jsxImportSource hono/jsx */`
**Important:** Files using JSX must have a `.tsx` extension. Rename `.ts` to `.tsx` or the compiler will fail.
### Components
```tsx
import type { PropsWithChildren } from 'hono/jsx'
const Layout = (props: PropsWithChildren) => (
<html>
<head>
<title>My App</title>
</head>
<body>{props.children}</body>
</html>
)
const UserCard = ({ name }: { name: string }) => (
<div class="card">
<h2>{name}</h2>
</div>
)
app.get('/', (c) => {
return c.html(
<Layout>
<UserCard name="Alice" />
</Layout>
)
})
```
### jsxRenderer Middleware
Use `jsxRenderer` middleware for layouts. For details, see https://hono.dev/docs/middleware/builtin/jsx-renderer
### Async Components
```tsx
const UserList = async () => {
const users = await fetchUsers()
return (
<ul>
{users.map((u) => (
<li>{u.name}</li>
))}
</ul>
)
}
```
### Fragments
```tsx
const Items = () => (
<>
<li>Item 1</li>
<li>Item 2</li>
</>
)
```
---
## Streaming
```ts
import { stream, streamText, streamSSE } from 'hono/streaming'
// Basic stream
app.get('/stream', (c) => {
return stream(c, async (stream) => {
stream.onAbort(() => console.log('Aborted'))
await stream.write(new Uint8Array([0x48, 0x65]))
await stream.pipe(readableStream)
})
})
// Text stream
app.get('/stream-text', (c) => {
return streamText(c, async (stream) => {
await stream.writeln('Hello')
await stream.sleep(1000)
await stream.write('World')
})
})
// Server-Sent Events
app.get('/sse', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (true) {
await stream.writeSSE({
data: JSON.stringify({ time: new Date().toISOString() }),
event: 'time-update',
id: String(id++)
})
await stream.sleep(1000)
}
})
})
```
---
## Testing with app.request()
Test endpoints without starting an HTTP server:
```ts
// GET
const res = await app.request('/posts')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ posts: [] })
// POST with JSON
const res = await app.request('/posts', {
method: 'POST',
body: JSON.stringify({ title: 'Hello' }),
headers: { 'Content-Type': 'application/json' }
})
// POST with FormData
const formData = new FormData()
formData.append('name', 'Alice')
const res = await app.request('/users', { method: 'POST', body: formData })
// With mock env (Cloudflare Workers bindings)
const res = await app.request('/api/data', {}, { KV: mockKV, DATABASE: mockDB })
// Using Request object
const req = new Request('http://localhost/api', { method: 'DELETE' })
const res = await app.request(req)
```
---
## Hono Client (RPC)
Type-safe API client using shared types between server and client.
**IMPORTANT: Routes MUST be chained for type inference to work. Without chaining, the client cannot infer route types.**
```ts
// Server: routes MUST be chained to preserve types
const route = app
.post('/posts', zValidator('json', schema), (c) => {
return c.json({ ok: true }, 201)
})
.get('/posts', (c) => {
return c.json({ posts: [] })
})
export type AppType = typeof route
// Client: use hc() with the exported type
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('http://localhost:8787/')
const res = await client.posts.$post({ json: { title: 'Hello' } })
const data = await res.json() // fully typed
```
Type utilities:
```ts
import type { InferRequestType, InferResponseType } from 'hono/client'
type ReqType = InferRequestType<typeof client.posts.$post>
type ResType = InferResponseType<typeof client.posts.$post, 200>
```
---
## Helpers
Helpers are utility functions imported from `hono/<helper-name>`:
```ts
import { getConnInfo } from 'hono/conninfo'
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
import { css, Style } from 'hono/css'
import { createFactory } from 'hono/factory'
import { html, raw } from 'hono/html'
import { stream, streamText, streamSSE } from 'hono/streaming'
import { testClient } from 'hono/testing'
import { upgradeWebSocket } from 'hono/cloudflare-workers' // or other adapter
```
Available helpers: Accepts, Adapter, ConnInfo, Cookie, css, Dev, Factory, html, JWT, Proxy, Route, SSG, Streaming, Testing, WebSocket.
For details, see `https://hono.dev/docs/helpers/<helper-name>` (fetch with `Accept: text/markdown`).
### Factory
Use `createFactory` to define `Env` once and share it across app, middleware, and handlers:
```ts
import { createFactory } from 'hono/factory'
const factory = createFactory<Env>()
// Create app (Env type is inherited)
const app = factory.createApp()
// Create middleware (Env type is inherited, no need to pass generics)
const mw = factory.createMiddleware(async (c, next) => {
await next()
})
// Create handlers separately (preserves type inference)
const handlers = factory.createHandlers(logger(), (c) => c.json({ message: 'Hello' }))
app.get('/api', ...handlers)
```
---
## Best Practices
- Write handlers inline in route definitions for proper type inference of path params.
- Use `app.route()` to organize large apps by feature, not Rails-style controllers.
- Use `createFactory()` to share Env type across app, middleware, and handlers.
- Use `c.set()`/`c.get()` to pass data between middleware and handlers.
- Chain validators for multiple request parts (param + query + json).
- Export app type for RPC: `export type AppType = typeof routes`
- Use `app.request()` for testing — no server startup needed.
## Adapters
Hono runs on multiple runtimes. The default export works for Cloudflare Workers, Deno, and Bun. For Node.js, use the Node adapter:
```ts
// Cloudflare Workers / Deno / Bun
export default app
// Node.js
import { serve } from '@hono/node-server'
serve(app)
```

View File

@@ -0,0 +1,320 @@
---
name: interface-design
description: Craft-first interface design for dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces, and interactive products. Use when designing, building, reviewing, auditing, or refining product UI where visual craft, layout hierarchy, tokens, states, visual direction, or design-system consistency matter. Not for marketing pages, landing pages, campaigns, or brand-only work.
---
# Interface Design
Build product interfaces with the craft of a top design team — Linear, Vercel, Stripe, Apple. The difference between those and generic output is not talent. It is that every decision was *decided*, the hierarchy is unmistakable, and a hundred small details are correct at once. This skill is how you get there.
## Scope
**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces.
**Not for:** Landing pages, marketing sites, campaigns, brand-only work. Use a marketing/frontend design skill for those.
This skill is self-contained: direction, visual hierarchy, design-system architecture, and the polish and motion essentials needed to ship production-grade UI all live here.
---
# The Problem
You will generate generic output. Your training has seen thousands of dashboards, and the patterns are strong. You can follow this entire process — explore the domain, name a signature, state your intent — and still produce a template: warm colors on cold structures, friendly fonts on generic layouts.
This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win. Process helps, but it doesn't guarantee craft. You have to catch yourself, and you have to know the concrete moves that defaults don't.
**The bar:** If another AI, given a similar prompt, would produce substantially the same output, you have failed. Not different for its own sake — different because the interface emerged from *this* user, *this* task, *this* world. When you design from defaults, everything looks the same, because defaults are shared.
---
# Where Defaults Hide
Defaults disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed.
- **Typography feels like a container.** But type isn't holding your design, it *is* your design. The weight of a headline, the personality of a label, the texture of a paragraph shape how the product feels before anyone reads a word. Reaching for your usual font means you're not designing.
- **Navigation feels like scaffolding.** But navigation *is* the product — where you are, where you can go, what matters. A page floating in space is a component demo, not software.
- **Data feels like presentation.** But a number on screen is not design. What does it *mean* to the person looking? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space.
- **Token names feel like implementation detail.** But `--ink` and `--parchment` evoke a world; `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should guess what product this is.
There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over.
---
# Intent First
Before touching code, answer these. Keep it a compact working brief unless the direction needs user confirmation.
- **Who is this human?** Not "users." The actual person. Where are they when they open this? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings.
- **What must they accomplish?** The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides.
- **What should this feel like?** In words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? This shapes color, type, spacing, density — everything.
If the prompt is too vague to identify the human, task, and feel, ask one concise question. If context allows a responsible assumption, state it briefly and proceed.
**Intent must be systemic.** Saying "warm" then using cold colors is not following through. If the intent is warm: surfaces, text, borders, accents, semantic colors, type — all warm. If dense: spacing, type size, information architecture — all dense. Check every token against the stated intent. For every choice — layout, color temperature, typeface, spacing scale, hierarchy — you must be able to say *why*. "It's common" or "it works" means you defaulted.
---
# Product Domain Exploration
This is where defaults get caught — or don't. Generic path: Task type → visual template → theme. Crafted path: Task type → product domain → signature → structure + expression. The difference is time spent in the product's world before any visual thinking.
**Produce all four before proposing any direction:**
- **Domain** — concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5.
- **Color world** — what colors exist *naturally* here? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? List 5+.
- **Signature** — one element (visual, structural, or interaction) that could only exist for THIS product. If you can't name one, keep exploring.
- **Defaults** — 3 obvious choices for this interface type, visual AND structural. You can't avoid patterns you haven't named.
**The test:** Read your proposal with the product name removed. Could someone identify what it's for? If not, explore deeper.
---
# Render It When You Can
If an inline visual-rendering tool is available in the session (e.g. a `show_widget` / `visualize` tool that renders HTML or SVG inline in the conversation), prefer **showing** the design over describing it. A direction trapped in prose is a fraction as useful as one the person can look at. This is conditional: when no such tool is present (CI, headless agents, plain terminals), fall back to code, tokens, and a written proposal. Never assume the tool exists; check, then use it.
Render at three moments:
1. **Proposing a direction.** Alongside the Suggest + Ask block, render a small live specimen: the palette as actual swatches, the type scale in the real typeface, the surface-elevation steps as stacked cards, the signature element as a real component. The person should *see* "warm like a notebook," not read the words.
2. **Designing a component.** Render the actual component (or a tight before/after, both variants side by side) so the craft decisions — spacing, borders, hierarchy, states — are visible, not asserted. Render the real states (default, hover, empty, error) where they matter.
3. **Critiquing or auditing.** Render the current version and the improved version together so the gap is shown, not narrated.
Rules when rendering:
- The widget shows the **visual only**. All reasoning, the domain exploration, the rejected-defaults list, and the recommendation stay in your response text — never paste prose into the widget.
- Match the rendering tool's own design-system contract (load its `read_me`/guidance if it has one). Use its theme variables so the specimen inherits light/dark mode and sits native in the host. Don't fight the host chrome.
- The specimen must still pass the checks below. A rendered default is still a default — rendering is how craft gets *seen*, not a substitute for it.
- This renders to the *conversation*, not the project. The actual implementation still lands in the codebase through normal edits.
The point: collapse the loop between "here's my thinking" and "here's what it looks like" into a single message the person can react to.
---
# Visual Hierarchy & Composition
The single biggest driver of "this looks designed" versus "this looks generated." Defaults produce *flatness* — everything the same size, weight, and spacing, so nothing leads and the eye has nowhere to go. Craft produces *hierarchy* — the eye knows instantly what matters. These are concrete moves, not vibes.
## One focal point per view
Every screen has one thing the user came to do. That thing dominates — through size, contrast, position, or the space around it. When everything competes equally, nothing wins and the interface reads like a parking lot. Before building, name the focal element out loud. Then make it win: bigger, higher-contrast, or ringed in whitespace. Demote everything else deliberately.
## Type scale is a ratio, and weight beats size
Don't pick sizes by feel. Pick a ratio and step it: ~1.2 (minor third) for dense/calm UI, ~1.25 for most product UI, ~1.333 for expressive. From a 14–16px body that yields a *visibly* distinct scale, not 15/16/17 mush. A 14px base at 1.25: `caption 11 · body 14 · h4 16 · h3 18 · h2 22 · h1 28 · display 44+`. Round to whole pixels and to your spacing grid.
The Apple/Linear move: **weight and color do more hierarchy work than size.** A single 14px size holds three tiers through weight + opacity alone — `value: 600 / primary`, `label: 500 / secondary`, `meta: 400 / muted` — separating more cleanly than two regular weights two points apart. Build from three levers together (size, weight, color/opacity), never size alone. If you squint and can't tell headline from body from label, the hierarchy is too weak.
Worked example — a metric, flat vs decided. *Flat:* `Revenue` / `$48,200` both 14px regular gray, three identical boxes, no focal point. *Decided:* `REVENUE` 11px/500/muted/tracked · `$48,200` 28px/600/primary/tabular-nums (the hero) · `↑12%` 12px/500/success. Same data, opposite legibility — the figure leads through size+weight+one accent, the label is demoted, secondary metrics drop to a lower tier.
## Density is a decision, expressed in px
Linear is tight; Stripe is airy. Neither is default — both are *chosen*, and the choice is the same number repeated everywhere. Decide the density up front and name the values: a tool panel at 12–16px padding feels workbench-tight; the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Pick deliberately, then hold it.
## Spatial rhythm — breathe unevenly
Great interfaces don't space everything equally. Dense control zones give way to open content; heavy elements balance against light ones; the eye travels with purpose. Monotone layouts — same card size, same gap, same density everywhere — are the sound of no one deciding. Vary the rhythm on purpose: group tightly-related things, then put real air between groups.
## Proportions speak
A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what a proportion is saying, it isn't saying anything. Choose widths and ratios that state a relationship.
## Distribution and restraint (the "expensive" look)
- **~60/30/10**: a dominant neutral surface, a secondary tone, and ~10% accent. Color is a scarce resource — most of the screen is structure.
- **One accent, used with intention**, beats five colors used without thought. Gray builds structure; color *communicates* (status, action, identity). Unmotivated color is noise.
- **Hierarchy through space and weight, not lines.** Reach for whitespace and tonal shift before borders and dividers. The most premium interfaces are mostly invisible structure.
- **Optical sizing on large type**: tighten letter-spacing as type gets bigger (headings slightly negative tracking); loosen line-height on body for readability (~1.5). Tight type reads as crafted; default tracking on a 32px heading reads as a document.
---
# Craft Foundations
## Subtle Layering (the backbone)
Regardless of direction, this applies to everything. You should *barely notice the system working* — when you look at Vercel's dashboard you don't think "nice borders," you just understand the structure. Invisible craft is working craft.
**Surface elevation.** Surfaces stack: a dropdown sits above a card sits above the page. Build a numbered system — base, then increasing levels. Each jump is only a few percentage points of lightness — e.g. dark mode base → +7% → +9% → +12%; light mode stays light and adds shadow instead. You can barely see one step in isolation, but stacked, the hierarchy emerges. Whisper-quiet shifts you feel rather than see.
- **Sidebars:** same background as canvas, not a different color. Different colors fragment the space into "sidebar world" and "content world." A subtle border is enough.
- **Dropdowns/popovers:** one level above their parent surface, or they blend in and layering is lost.
- **Inputs:** slightly *darker* than surroundings, not lighter. Inputs are inset — they receive content. A darker fill signals "type here" without heavy borders.
**Borders.** Should disappear when you're not looking for them, but be findable when you need structure. Low-opacity rgba blends with the background and defines an edge without demanding attention; solid hex borders look harsh by comparison. Dark mode lives around `rgba(255,255,255,0.06–0.12)`, light mode slightly higher. Build a progression — standard, softer separation, emphasis, focus-ring — and match intensity to the importance of the boundary.
**The squint test:** blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide — but nothing should jump out. No harsh lines, no jarring shifts. Just quiet structure. Get this wrong and nothing else matters.
## Infinite Expression
Every pattern has infinite expressions — **no two interfaces should look the same.** A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, or trend badge. Same sidebar width, same card grid, same icon-left-number-big-label-small metric boxes every time *signals AI-generated immediately* and is forgettable. Linear's cards don't look like Notion's; Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions. Before building, ask: what's the ONE thing users do here, and what product solves a similar problem brilliantly?
## Color Lives Somewhere
Every product exists in a world, and that world has colors. Before reaching for a palette, walk into the physical version of this space — what materials, what light, what objects? Your palette should feel like it came FROM somewhere, not applied TO something. Temperature is one axis; also ask quiet or loud, dense or spacious, serious or playful, geometric or organic. A trading terminal and a meditation app are both "focused" — completely different kinds of focus.
---
# Before Writing Each Component
**Every time** you write UI code — even small additions — state:
```
Intent: [who is this human, what must they do, how should it feel]
Hierarchy: [the focal element, and how it wins — size / weight / contrast / space]
Palette: [colors from your exploration — and WHY they fit this world]
Depth: [borders / subtle shadows / layered — and WHY it fits the intent]
Surfaces: [your elevation scale — and WHY this temperature]
Typography: [typeface + the size/weight/color levers — and WHY]
Spacing: [base unit + chosen density]
```
This checkpoint is mandatory. If you can't explain WHY for each, you're defaulting — stop and think.
---
# Use What Exists
The most common way AI degrades a codebase: it hand-rolls what already exists. A bespoke `<div onClick>` "button" beside the project's real `Button`. A from-scratch dropdown with no keyboard support beside an installed primitive that has it. A 14-class Tailwind string copy-pasted onto every card instead of the component or token that's right there. Every one of these is the same failure — generating new instead of using what's present — and the result is inconsistent, inaccessible, and unmaintainable. **Before you build a control or style an element, look at what the project already gives you.**
## Controls: native → primitive → hand-roll
1. **Native HTML first** where it works. A `<button>` is a button; an `<a>` is a link; `<input type="text">`, `<dialog>`, `<details>` exist. Never `<div onClick>` something the platform already provides — you lose focus, keyboard, and semantics for free.
2. **A battle-tested headless primitive** for anything stateful and hard to get right — select, combobox, dialog, popover, tooltip, dropdown menu, tabs, date picker. These ship keyboard navigation, focus management, ARIA, and collision/positioning that take days to reproduce correctly. Reach for what the ecosystem already trusts (e.g. Radix UI, React Aria, Ark, Headless UI, Vaul, `cmdk` in React; the equivalent accessible primitive in other frameworks), then style it to your direction. "Build custom" means *compose and style a primitive*, not write the behavior from scratch.
3. **Hand-roll only as a genuine last resort** — no primitive fits, or there's no dependency budget. Then you owe the complete behavior contract: keyboard nav (arrow keys, Enter, Escape), focus trap/return, full ARIA roles and state, click-outside, and scroll-lock for overlays. A styled control missing these is broken, however good it looks.
## Styling: system → component → token → utility
1. **If the project has a design system, use it.** shadcn/`Button`, a CVA variant set, a theme, a component library — use `<Button variant="…">` and the existing variants before writing a one-off. Match the codebase's styling convention (Tailwind, CSS modules, CSS-in-JS), don't introduce your own.
2. **When a styled element repeats, extract a component.** The same utility string on nine buttons is duplication, not design. One component (or one CVA/variant) owns it; call sites stay clean. Extract on the second real reuse, not the first.
3. **Bind to semantic tokens, not hardcoded literals.** `bg-card border-border text-muted-foreground`, not `bg-white border-gray-200 text-gray-500`. Hardcoded `gray-200`/`#fff`/`px-4` raw values are the Tailwind form of random hex — they break theming and dark mode and signal no system. (See Token Architecture below.)
4. **Inline utilities are for genuine one-offs** — a layout nudge used once. The tell of slop is the *same* long className sprayed everywhere; that's a missing component or token, not styling.
---
# Design System Essentials
The token, spacing, and depth architecture beneath every craft decision.
- **Token architecture.** Every color traces to a small set of primitives: foreground (text), background (surface), border, brand, semantic (destructive/warning/success). No random hex — everything maps to primitives.
- **Text hierarchy — four levels.** Primary, secondary, tertiary, muted (default / supporting / metadata / disabled). Using only two means the hierarchy is too flat.
- **Spacing.** Pick a base unit (4 or 8px), use multiples only. Scale by context: micro (icon gaps), component (within buttons/cards), section (between groups), major (between areas). Random values signal no system.
- **Padding.** Symmetrical — if one side has a value, the others match unless content genuinely demands asymmetry.
- **Depth — choose ONE and commit.** Borders-only (clean, technical, dense tools) · subtle shadows (approachable) · layered shadows (premium, dimensional) · surface-color shifts (tints, no shadows). Don't mix strategies.
- **Border radius — a scale.** Small for inputs/buttons, medium for cards, large for modals. Don't mix sharp and soft randomly.
- **Control tokens.** Inputs/selects/checkboxes get dedicated background, border, and focus tokens — don't reuse surface tokens, so you can tune controls independently. Native `<select>`/`<input type="date">` can't be styled — compose a headless primitive instead of hand-rolling one (see "Use What Exists").
- **Dark mode.** Shadows are weak on dark — lean on borders. Desaturate semantic colors slightly. Same hierarchy system, inverted values. Keep one hue; shift only lightness across surfaces.
---
# Polish & Motion Essentials
A hundred small details compound into "feels great." These are the highest-leverage ones — enough to ship genuinely polished UI.
## Static polish
- **Concentric radius.** Nested rounded elements: `outerRadius = innerRadius + padding`. Same radius on parent and child is the most common thing that makes UI feel off.
- **Tabular numbers.** Any dynamic number (counters, prices, timers, table columns) gets `font-variant-numeric: tabular-nums` to prevent layout shift.
- **Optical alignment.** When geometric centering looks off, fix it optically — icon-side padding ≈ text-side − 2px; nudge play triangles ~2px right.
- **States are not optional.** Every interactive element needs default, hover, active, focus, disabled. Data needs loading, empty, error. Missing states feel broken — they're the fastest tell of an unfinished interface.
- **Hit areas — 44×44px (WCAG), 40 at minimum.** If the visible control is smaller (a 20px checkbox), extend with a pseudo-element. Never let two hit areas overlap.
- **Shadows over borders for elevation.** For cards/buttons/containers that lift, prefer a layered transparent `box-shadow` (it adapts to any background); keep real borders for dividers and input outlines. Light-mode lift stacks three layers — a 1px ring + two soft depths, e.g. `0 0 0 1px rgba(0,0,0,.06), 0 1px 2px -1px rgba(0,0,0,.06), 0 2px 4px rgba(0,0,0,.04)`; dark mode collapses to a single ring `0 0 0 1px rgba(255,255,255,.08)` (depth shadows don't read on dark).
- **Text wrapping.** `text-wrap: balance` on headings; `text-wrap: pretty` on body/captions to kill orphans.
- **Font smoothing.** `-webkit-font-smoothing: antialiased` on the root (macOS renders heavy otherwise).
- **Image outlines.** 1px inset outline, pure `rgba(0,0,0,0.1)` light / `rgba(255,255,255,0.1)` dark — never a tinted near-black/white (reads as dirt on the edge).
## Motion
Motion should be felt, not watched. Fast, purposeful, and *never* in the way.
- **Should it animate at all?** Actions repeated 100+×/day (keyboard shortcuts, command palette) get **no** animation — it makes them feel slow. Occasional surfaces (modals, drawers, toasts) get standard animation. Rare/first-run moments can add delight.
- **Duration < 300ms** for UI. Button press 100–160ms; tooltips/popovers 125–200ms; dropdowns 150–250ms; modals/drawers 200–500ms. A 180ms dropdown feels more responsive than a 400ms one.
- **Custom ease-out, never ease-in.** Built-in curves are too weak. Use `cubic-bezier(0.23, 1, 0.32, 1)` for entering/interactive; ease-in-out (`cubic-bezier(0.77, 0, 0.175, 1)`) for on-screen movement. `ease-in` delays the first frame — the moment the user is watching — and feels sluggish.
- **Press feedback.** `transform: scale(0.97)` on `:active` (never below 0.95). Tactile confirmation the UI heard the click.
- **Never animate from `scale(0)`.** Nothing appears from nothing — start at `scale(0.95)` + `opacity: 0`.
- **Origin-aware popovers.** Popovers scale from their trigger (`transform-origin` set to the trigger), not center. Modals are the exception — they stay centered.
- **Only animate `transform` and `opacity`** (GPU-composited). Animating width/height/margin/padding triggers layout + paint and drops frames. Never `transition: all` — name exact properties.
- **Stagger entrances** 30–80ms between items for a natural cascade; keep exits faster and subtler than enters.
- **Respect `prefers-reduced-motion`** — keep opacity/color transitions, drop movement.
---
# Avoid
- **Harsh borders** — if borders are the first thing you see, they're too strong
- **Dramatic surface jumps** — elevation should be whisper-quiet
- **Flat hierarchy** — everything one size/weight; no clear focal point
- **Monotone layout** — same card size, gap, and density everywhere
- **Inconsistent spacing** — the clearest sign of no system
- **Mixed depth strategies** — pick one and commit
- **Missing states** — hover, focus, disabled, loading, empty, error
- **Dramatic drop shadows** — subtle, not attention-grabbing
- **Large radius on small elements**; **thick decorative borders**
- **Gradients and color for decoration** — color should mean something
- **Multiple accent colors** — dilutes focus
- **Different hues for different surfaces** — keep one hue, shift only lightness
- **Default typography** — system/Inter fonts and size-only hierarchy where a direction was set
- **Structural hacks** — negative margins undoing parent padding, escape-hatch `calc()`, absolute positioning to dodge layout flow
---
# Workflow
## Communication
Be invisible. Don't announce modes or narrate process. Never say "I'm in ESTABLISH MODE" or "Let me check system.md." Jump into the work; state suggestions with reasoning.
## Execution discipline
Use this skill as a working discipline, not just advice. When editing UI:
1. Inspect the existing app, design tokens, component patterns, and `.interface-design/system.md` if present.
2. Make the domain exploration concrete before choosing layout, color, type, density, and navigation.
3. For greenfield screens, major redesigns, or vague direction, consider a visual reference pass *if an image-generation tool is available* (skip otherwise — it's a companion, never the deliverable). Four modes: **direction board** (2–3 abstract mood/material/color explorations before code — no real UI), **UI reference** (medium-fidelity composition for a chosen direction), **paintover** (a stronger version of an existing screenshot), **raster asset** (empty-state art, textures — never icons/logos/charts). Always reject generic SaaS, illegible text, off-domain palettes; extract palette/density/proportions/signature, then build it in real code and verify in the browser.
4. Patch the implementation, then run the relevant build/typecheck/tests when available.
5. Verify visually for non-trivial UI. Use the inline render tool, a local browser, or screenshots at desktop and mobile widths; fix overlap, broken spacing, blank states, unreadable text, missing assets, and generic composition before presenting.
6. Keep user-facing updates short. Don't expose long private design monologues — surface the useful recommendation or decision.
## Suggest + Ask
Lead with exploration and recommendation, then confirm:
```
Domain: [5+ concepts from the product's world]
Color world:[5+ colors that exist in this domain]
Signature: [one element unique to this product]
Rejecting: [default 1] → [alternative], [default 2] → [alternative], [default 3] → [alternative]
Direction: [approach connecting to the above]
```
Then ask: "Does that direction feel right?" If an inline render tool is available, render a live specimen of the direction in the same message — show the palette, type, and signature, don't just name them.
## Build flow
- **If `.interface-design/system.md` exists:** read it and apply — decisions are made.
- **If not:** explore domain (all four outputs) → propose (reference all four) → confirm when the direction is ambiguous or costly to change → build → run the checks below → offer to save.
## The checks (before showing)
Run these against your output; if any fails, iterate before presenting.
- **Swap test** — swap your typeface for the usual one, your layout for a standard template: would anything feel different? Where swapping wouldn't matter is where you defaulted.
- **Squint test** — blur your eyes: hierarchy still readable? Nothing jumping out harshly?
- **Signature test** — point to five specific elements where your signature appears. "The overall feel" doesn't count.
- **Token test** — read your CSS variables aloud: do they belong to this product's world, or any project?
---
# After Completing a Task
Always offer to save: "Want me to save these patterns for future sessions?" If yes, write to `.interface-design/system.md`:
- Direction and feel
- Depth strategy (borders/shadows/layered) and spacing base unit
- Hierarchy decisions (type scale ratio, density values, focal pattern)
- Key component patterns — add when a component is used 2+ times, is reusable, or has measurements worth remembering (not one-offs or prop variations). Record the values, e.g. `Button primary — 36px h · 12px 16px pad · 6px radius · 14px/500`.
- Visual direction notes and selected references when an image pass shaped the design
**Consistency checks.** If system.md defines values, hold to them: spacing on the grid, the declared depth strategy throughout, colors from the palette, documented patterns reused not reinvented. This compounds — each save makes future work faster and more consistent.
---
# Commands
- `/interface-design:design-review` — strict craft + hierarchy review of a build, with an approval bar; renders before/after when possible
- `/interface-design:design-deslop` — fast, diff-scoped pass that strips visual slop from a branch
If a user asks for design status, audit, or pattern extraction in natural language: read `.interface-design/system.md` and summarize it, check UI files against it for drift, or scan for repeated spacing/radius/color/component values and propose a system.md — perform the equivalent inline.

View File

@@ -0,0 +1,6 @@
interface:
display_name: "Interface Design"
short_description: "Craft-first product UI guidance"
default_prompt: "Use $interface-design to design or refine a product interface with a domain-specific visual system and image-based references when useful."
policy:
allow_implicit_invocation: true

View File

@@ -0,0 +1,187 @@
---
name: make-interfaces-feel-better
description: >-
Design engineering principles for making interfaces feel polished. Use when building UI components, reviewing frontend code, implementing animations, hover states, shadows, borders, typography, icons, micro-interactions, enter/exit animations, or any visual detail work. Supports quick and full review modes. Triggers on UI polish, design details, "make it feel better", "feels off", stagger animations, border radius, optical alignment, font smoothing, tabular numbers, image outlines, box shadows, icons, icon stroke weight, icon states, motion restraint.
---
# Details that make interfaces feel better
Great interfaces rarely come from a single thing. It's usually a collection of small details that compound into a great experience. Apply these principles when building or reviewing UI code. Before suggesting or writing a fix, identify the project's existing styling system and express the change in that system: Tailwind in a Tailwind project, plain CSS in a CSS project, or the established CSS-in-JS approach. Never introduce a second styling system just to apply a polish fix.
When reviewing, slow the interface down: replay motion at 10% speed in the browser's Animations panel and walk every state: hover, focus, active, loading, empty. What feels off at 10% speed is what's subtly wrong at full speed.
## Quick Reference
| Category | When to Use |
| --- | --- |
| [Typography](typography.md) | Text wrapping, font smoothing, tabular numbers |
| [Surfaces](surfaces.md) | Border radius, optical alignment, shadows, image outlines, hit areas |
| [Animations](animations.md) | Interruptible animations, enter/exit transitions, icon animations, scale on press, motion restraint |
| [Icons](icons.md) | Icon stroke weight, states via `currentColor`, outline vs fill, sizing, RTL flipping |
| [Performance](performance.md) | Transition specificity, `will-change` usage |
## Core Principles
### 1. Concentric Border Radius
Outer radius = inner radius + padding. Mismatched radii on nested elements is the most common thing that makes interfaces feel off.
### 2. Optical Over Geometric Alignment
When geometric centering looks off, align optically. Buttons with icons, play triangles, and asymmetric icons all need manual adjustment.
### 3. Shadows for Elevation, Borders for Structure
For buttons, cards, and containers whose border exists only to create depth, prefer layered transparent `box-shadow` values. Keep borders that communicate structure or state: dividers, layout separators, and selected or focus states.
### 4. Interruptible Animations
Use CSS transitions for interactive state changes — they can be interrupted mid-animation. Reserve keyframes for staged sequences that run once.
### 5. Split and Stagger Enter Animations
For an infrequent staged entrance where sequence helps communicate hierarchy, break content into semantic chunks and stagger them by ~100ms instead of animating one container. Do not stagger routine, high-frequency interactions.
### 6. Subtle Exit Animations
Use a small fixed `translateY` instead of full height. Exits should be softer than enters. Use `ease-out` for both enter and exit transitions.
### 7. Contextual Icon Animations
Animate icons with `opacity`, `scale`, and `blur` instead of toggling visibility. Use exactly these values: scale from `0.25` to `1`, opacity from `0` to `1`, blur from `4px` to `0px`. If the project has `motion` or `framer-motion` in `package.json`, match that package's import path (or the established nearby imports when both exist) and use `transition: { type: "spring", duration: 0.3, bounce: 0 }` — bounce must always be `0`. If no motion library is installed, keep both icons in the DOM (one absolute-positioned) and cross-fade with CSS transitions using `cubic-bezier(0.2, 0, 0, 1)` — this gives both enter and exit animations without any dependency.
### 8. Font Smoothing
Apply `-webkit-font-smoothing: antialiased` to the root layout on macOS for crisper text.
### 9. Tabular Numbers
Use `font-variant-numeric: tabular-nums` for any dynamically updating numbers to prevent layout shift.
### 10. Text Wrapping
Use `text-wrap: balance` on headings. Use `text-wrap: pretty` for body text to avoid orphans.
### 11. Image Outlines
Add a subtle `1px` outline with low opacity to images for consistent depth. The color must be pure black in light mode (`oklch(0 0 0 / 0.1)`) and pure white in dark mode (`oklch(1 0 0 / 0.1)`), never a near-black like slate, zinc, or any tinted neutral. A tinted outline picks up the surface color underneath it and reads as dirt on the image edge.
### 12. Scale on Press
A subtle `scale(0.96)` on click gives buttons tactile feedback. Always use `0.96`. Never use a value smaller than `0.95` — anything below feels exaggerated. Add a `static` prop to disable it when motion would be distracting.
### 13. Skip Animation on Page Load
Use `initial={false}` on `AnimatePresence` to prevent enter animations on first render. Verify it doesn't break intentional entrance animations.
### 14. Never Use `transition: all`
Always specify exact properties: `transition-property: scale, opacity`. Tailwind's `transition-transform` covers `transform, translate, scale, rotate`.
### 15. Use `will-change` Sparingly
Only for `transform`, `opacity`, `filter` — properties the GPU can composite. Never use `will-change: all`. Only add when you notice first-frame stutter.
### 16. Minimum Hit Area
Interactive elements should prefer a 44×44px hit area for touch or mobile contexts. In dense desktop interfaces, use at least 40×40px. Extend with a pseudo-element if the visible element is smaller. Never let hit areas of two elements overlap.
### 17. Match Icon Stroke to Text Weight
An icon next to text carries the text's optical weight: `1.5px` stroke beside regular (400) text, `2px` beside semibold (600). One stroke weight per icon set; never mix libraries on one surface.
### 18. One SVG, Recolored per State
Icons use `currentColor` and get their states (hover, selected, disabled) from CSS color and opacity, never from separate assets. Outline variant is the default; fill variant marks the active state.
### 19. Motion Restraint
No custom animation on high-frequency interactions: the attention cost repeats on every trigger. Motion is never the only feedback channel; every animated state change also needs a static cue such as color, icon, or label.
## Common Mistakes
| Mistake | Fix |
| --- | --- |
| Same border radius on parent and child | Calculate `outerRadius = innerRadius + padding` |
| Icons look off-center | Adjust optically with padding or fix SVG directly |
| Border used only to fake elevation | Use layered `box-shadow` with transparency; keep structural and state borders |
| Jarring staged entrance or contextual exit | Stagger infrequent entrances and keep context-preserving exits subtle |
| Numbers cause layout shift | Apply `tabular-nums` |
| Heavy text on macOS | Apply `antialiased` to root |
| Animation plays on page load | Add `initial={false}` to `AnimatePresence` |
| `transition: all` on elements | Specify exact properties |
| First-frame animation stutter | Add `will-change: transform` (sparingly) |
| Tiny hit areas on small controls | Extend with a pseudo-element to 44×44px for touch/mobile, or at least 40×40px in dense desktop UI |
| Hairline icon beside bold text | Match the stroke width to the text weight |
| Separate icon assets per state | One `currentColor` SVG, states via CSS |
| Filled icons everywhere | Outline as default, fill only for the active state |
| Entrance animation on every hover or keystroke | Instant feedback or ≤150ms opacity/color transition |
## Review Output Format
Use `full` when no review mode is supplied.
| Mode | Coverage | Finding cap |
| --- | --- | --- |
| `quick` | Primary user path and highest-traffic states; report only `HIGH` and `MEDIUM` issues | 5 |
| `full` | Entire requested scope across typography, surfaces, animations, icons, and performance | 15 |
### Scope and Coverage
State the mode, exact scope, framework, styling conventions, and any review boundary. Show what was actually inspected:
| Category | Evidence inspected | Result |
| --- | --- | --- |
| Typography | Files, components, states, or checks | Findings count, `Clear`, or `Not reviewed` with a reason |
Include all five Quick Reference categories. Never imply an uninspected surface was reviewed.
### Findings
Group findings by principle. Use a markdown table with **Severity**, **Location**, **Before**, **After**, and **Why** columns. Include every change made or proposed, not a subset. Never use separate "Before:" / "After:" lines.
- **Severity**: `HIGH` makes an interaction inaccessible, misleading, unreadable, or repeatedly disruptive; `MEDIUM` creates a noticeable usability or consistency problem; `LOW` is isolated polish and appears only in `full` mode.
- **Location**: cite `path/to/file:line`. If the artifact has no source files, cite the exact screen and component instead.
- **Before / After**: show the current implementation and an actionable replacement.
- **Why**: name the violated principle and explain its user impact.
Consolidate a repeated systemic issue into one row and list every affected location. Omit principles with no findings and never pad the report to reach the cap.
### Example
#### Concentric border radius
| Severity | Location | Before | After | Why |
| --- | --- | --- | --- | --- |
| LOW | `src/Card.tsx:28` | `rounded-xl` on card + `rounded-xl` on inner button (`p-2`) | `rounded-2xl` on card (`8 + 8 = 16`), `rounded-lg` on inner button | Nested corners should be concentric |
| LOW | `src/card.css:11` | `border-radius: 16px` on both nested surfaces | Outer `24px`, inner `16px` with `8px` padding | Equal nested radii make the inner surface look pinched |
#### Tabular numbers
| Severity | Location | Before | After | Why |
| --- | --- | --- | --- | --- |
| MEDIUM | `src/Counter.tsx:17` | `<span>{count}</span>` | `<span className="tabular-nums">{count}</span>` | Proportional digits cause changing values to shift |
| LOW | `src/timer.css:8` | Default numerals on a timer | Add `font-variant-numeric: tabular-nums` to the timer | Equal-width digits keep the timer stable |
#### Scale on press
| Severity | Location | Before | After | Why |
| --- | --- | --- | --- | --- |
| LOW | `src/Button.tsx:19` | `<button className="...">` | Add `active:scale-[0.96] transition-transform` | Press feedback makes the control feel responsive |
| MEDIUM | `src/button.css:24` | `scale(0.9)` on press | Raise to `scale(0.96)` | Anything below `0.95` feels exaggerated |
### Considered but Rejected
Include 1–3 real candidates in `quick` mode and 2–5 in `full` mode:
| Location | Candidate | Rejected because |
| --- | --- | --- |
| `src/Card.tsx:28` | Increase the shadow | Existing depth matches the shared surface token; changing one card would reduce consistency |
Do not invent filler. If the scope contains fewer borderline candidates, include the ones that exist and say so.
### Verification and Verdict
After the findings:
1. **Verification**: list the exact commands or interactions run and their observed results. Walk every relevant state and inspect motion at 10% speed when animation is involved. If a check was not run, label it **Not verified** and state what remains.
2. **Verdict**: `Block` if any `HIGH` finding remains, `Needs changes` if only `MEDIUM` or `LOW` findings remain, and `Approve` only when no actionable findings remain. List every unverified check beside the verdict.
When there are no findings, omit the findings table, state "No actionable interface-polish findings", report verification and rejected candidates, and end with `Approve`.

View File

@@ -0,0 +1,3 @@
interface:
display_name: "Make Interfaces Feel Better"
short_description: "Typography, surfaces, motion, icons and UI polish"

View File

@@ -0,0 +1,403 @@
# Animations
Interruptible animations, enter/exit transitions, contextual icon animations, and motion restraint.
## Interruptible Animations
Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.
### CSS Transitions vs. Keyframes
| | CSS Transitions | CSS Keyframe Animations |
| --- | --- | --- |
| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |
| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |
| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |
| **Duration** | Fixed; retargets the value mid-flight, not the timeline | Fixed timeline, restarts from the beginning |
```css
/* Good — interruptible transition for a toggle */
.drawer {
transform: translateX(-100%);
transition: transform 200ms ease-out;
}
.drawer.open {
transform: translateX(0);
}
/* Clicking again mid-animation smoothly reverses — no jank */
```
```css
/* Bad — keyframe animation for interactive element */
.drawer.open {
animation: slideIn 200ms ease-out forwards;
}
/* Closing mid-animation snaps or restarts — feels broken */
```
**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.
## Enter Animations: Split and Stagger
Use this pattern for infrequent staged entrances where sequence helps communicate hierarchy, such as the first load of a page hero, success state, or empty state. Break a large container into semantic chunks and animate each individually. Do not stagger routine interactions such as row hovers, keystrokes, or repeated tab changes.
### Step by Step
1. **Split** into logical groups (title, description, buttons)
2. **Stagger** with ~100ms delay between groups
3. **For titles**, consider splitting into individual words with ~80ms stagger
4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect
### Code Example
```tsx
// Motion (Framer Motion) — staggered enter
function PageHeader() {
return (
<motion.div
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } },
}}
>
<motion.h1
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
Welcome
</motion.h1>
<motion.p
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
A description of the page.
</motion.p>
<motion.div
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
>
<Button>Get started</Button>
</motion.div>
</motion.div>
);
}
```
### CSS-Only Stagger
```css
.stagger-item {
opacity: 0;
transform: translateY(12px);
filter: blur(4px);
animation: fadeInUp 400ms ease-out forwards;
}
.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
```
## Exit Animations
Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention.
### Subtle Exit (Recommended)
```tsx
// Small fixed translateY — indicates direction without drama
<motion.div
exit={{
opacity: 0,
y: -12,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeOut" },
}}
>
{content}
</motion.div>
```
### Full Exit (When Context Matters)
```tsx
// Slide fully out — use when spatial context is important
// (e.g., a card returning to a list, a drawer closing)
<motion.div
exit={{
opacity: 0,
x: "-100%",
transition: { duration: 0.2, ease: "easeOut" },
}}
>
{content}
</motion.div>
```
### Good vs. Bad
```css
/* Good — subtle exit */
.item-exit {
opacity: 0;
transform: translateY(-12px);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
/* Bad — dramatic exit that steals focus */
.item-exit {
opacity: 0;
transform: translateY(-100%) scale(0.5);
transition: all 400ms ease-out;
}
/* Sometimes correct — remove immediately when motion adds no context */
.item-exit {
display: none;
}
```
**Key points:**
- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height
- Keep some directional movement to indicate where the element went
- Exit duration should be shorter than enter duration (150ms vs 300ms)
- Use a subtle exit when it preserves spatial context. Remove immediately when motion adds no information, the interaction repeats frequently, or reduced motion is requested.
## Contextual Icon Animations
When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.
### Motion Example
This example uses the `motion` package. If the project instead has `framer-motion`, import the same APIs from `"framer-motion"`; never mix an installed package with the other package's import path.
```tsx
import { AnimatePresence, motion } from "motion/react";
function IconButton({ isActive, icon: Icon }) {
return (
<button>
<AnimatePresence mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<Icon />
</motion.span>
</AnimatePresence>
</button>
);
}
```
### CSS Transition Approach (No Motion)
If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly.
The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur.
```tsx
function IconButton({ isActive, ActiveIcon, InactiveIcon }) {
return (
<button>
<div className="relative">
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
"transition-[opacity,filter,scale] duration-300",
"ease-[cubic-bezier(0.2,0,0,1)]",
isActive
? "scale-100 opacity-100 blur-0"
: "scale-[0.25] opacity-0 blur-[4px]"
)}
>
<ActiveIcon />
</div>
<div
className={cn(
"transition-[opacity,filter,scale] duration-300",
"ease-[cubic-bezier(0.2,0,0,1)]",
isActive
? "scale-[0.25] opacity-0 blur-[4px]"
: "scale-100 opacity-100 blur-0"
)}
>
<InactiveIcon />
</div>
</div>
</button>
);
}
```
The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.
### Choosing Between Motion and CSS
| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |
| --- | --- | --- |
| **Enter animation** | Yes | Yes |
| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |
| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |
| **When to use** | Project already uses `motion` or `framer-motion` | No motion dependency, or keeping bundle small |
**Rule:** Check the project's `package.json`. Import from `"motion/react"` when `motion` is installed, or from `"framer-motion"` when `framer-motion` is installed. If both exist, follow the imports already used by the component or its nearest peers. If neither is present, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.
### When to Animate Icons
| Animate | Don't animate |
| --- | --- |
| Icons that appear on hover (action buttons) | Static navigation icons |
| State change icons (play → pause, like → liked) | Decorative icons |
| Icons in contextual toolbars | Icons that are always visible |
| Loading/success state indicators | Icon labels (text next to icon) |
**Important:** Always use exactly these values for contextual icon animations — do not deviate:
- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)
- `opacity`: `0` → `1`
- `filter`: `"blur(4px)"` → `"blur(0px)"`
- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value
## Scale on Press
A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return.
Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.
### CSS Example
```css
.button {
transition-property: scale;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.button:active {
scale: 0.96;
}
```
### Tailwind Example
```tsx
<button className="transition-transform duration-150 ease-out active:scale-[0.96]">
Click me
</button>
```
### Motion Example
```tsx
<motion.button whileTap={{ scale: 0.96 }}>
Click me
</motion.button>
```
### Static Prop Pattern
Extract the scale class into a variable and conditionally apply it based on a `static` prop:
```tsx
const tapScale = "active:not-disabled:scale-[0.96]";
function Button({ static: isStatic, className, children, ...props }) {
return (
<button
className={cn(
"transition-transform duration-150 ease-out",
!isStatic && tapScale,
className,
)}
{...props}
>
{children}
</button>
);
}
// Usage
<Button>Click me</Button> {/* scales on press */}
<Button static>Submit</Button> {/* no scale */}
```
## Skip Animation on Page Load
Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes.
### When It Works
```tsx
// Good — icon doesn't animate in on mount, only on state change
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={isActive ? "active" : "inactive"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
>
<Icon />
</motion.span>
</AnimatePresence>
```
Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.
### When It Breaks
Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance.
```tsx
// Bad — initial={false} would skip the staggered page enter entirely
<AnimatePresence initial={false}>
<motion.div initial="hidden" animate="visible" variants={...}>
...
</motion.div>
</AnimatePresence>
```
Verify the component still looks right on a full page refresh before applying this.
## Motion Restraint
Motion is a budget, not a garnish:
- **No custom animation on high-frequency interactions.** Repeated interactions get instant feedback or a minimal `opacity` or `background-color` transition at ≤150ms.
- **Motion is never the only feedback channel.** Every animated state change also needs a static cue such as color, icon, or label.
- **Brief and precise beats prominent.** If a shorter, smaller animation communicates the same thing, use it.
- **Honor reduced-motion preferences.** Preserve the static cue and remove unnecessary movement.
```css
/* Good: high-frequency hover gets a minimal transition */
.row:hover {
background-color: var(--surface-hover);
transition: background-color 100ms ease-out;
}
/* Bad: every hover replays a full entrance */
.row:hover .row-icon {
animation: bounceIn 500ms;
}
```

View File

@@ -0,0 +1,63 @@
# Icons
Icon weight, states, sizing, and direction: the details that make icons sit naturally in an interface.
## Match Icon Stroke to Text Weight
An icon next to text should carry the same optical weight as the text.
| Adjacent text | Icon stroke width (24px grid) |
| --- | --- |
| Regular (400), 14–16px | `1.5px` |
| Medium/Semibold (500–600) | `2px` |
| Bold (700), or emphasized standalone | `2.5px` |
Use one stroke weight per icon set on a surface. Size inline icons relative to the text's cap height, typically `1em`–`1.25em`.
## One SVG, Recolored per State
Use one SVG drawn with `currentColor`; let CSS drive hover, selected, and disabled states. Strip hardcoded `fill` and `stroke` colors when importing icons.
```html
<svg fill="none" stroke="currentColor" stroke-width="2">…</svg>
```
```css
.icon-button { color: oklch(0.552 0.016 285.938); }
.icon-button:hover { color: oklch(0.21 0.006 285.885); }
.icon-button[aria-pressed="true"] { color: oklch(0.623 0.188 259.815); }
.icon-button:disabled { opacity: 0.4; }
```
## Outline Default, Fill Active
| Variant | Use for |
| --- | --- |
| Outline | Default state: toolbars, list rows, inline with text |
| Fill | Selected or active state: active tab, toggled bookmark, liked heart |
The swap between variants is a contextual icon animation; use the exact cross-fade values in [animations.md](animations.md).
## Design at Render Size
- Test every icon at the smallest size it will render, often `16px`.
- Prefer simplified glyphs for small contexts over scaled-down detailed artwork.
- Use the icon set's native grid sizes (`16`, `20`, `24`) rather than arbitrary fractional scales.
- Use SVG rather than raster assets.
## Icons in RTL
| Flip | Don't flip |
| --- | --- |
| Back/forward arrows, navigation chevrons | Logos and brand marks |
| Text alignment, lists, indent | Checkmarks |
| Directional send glyphs | Clocks, cups, pencils |
| Speaker waves tied to reading direction | Media playback controls |
```css
[dir="rtl"] .icon-directional {
scale: -1 1;
}
```
Analyze composite icons part by part: an overlay may keep its position even when the base glyph flips. Give every icon-only control an accessible name and mark purely decorative icons hidden from assistive technology.

View File

@@ -0,0 +1,88 @@
# Performance
Transition specificity and GPU compositing hints.
## Transition Only What Changes
Never use `transition: all` or Tailwind's `transition-all`. Always specify the exact properties that change. Tailwind's bare `transition` maps to a curated default list of colors, opacity, shadow, and transforms, not to `all`; still prefer naming exactly what changes.
### Why
- `transition: all` forces the browser to watch every property for changes
- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows)
- Prevents browser optimizations
### CSS Example
```css
/* Good — only transition what changes */
.button {
transition-property: scale, background-color;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
/* Bad — transition everything */
.button {
transition: all 150ms ease-out;
}
```
### Tailwind
```tsx
// Good — explicit properties
<button className="transition-[scale,background-color] duration-150 ease-out">
// Bad — transition all
<button className="transition-all duration-150 ease-out">
```
### Tailwind `transition-transform` Note
`transition-transform` in Tailwind maps to `transition-property: transform, translate, scale, rotate` — it covers all transform-related properties, not just `transform`. Use this when you're only animating transforms. For multiple non-transform properties, use the bracket syntax: `transition-[scale,opacity,filter]`.
## Use `will-change` Sparingly
`will-change` hints the browser to pre-promote an element to its own GPU compositing layer. Without it, the browser promotes the element only when the animation starts — that one-time layer promotion can cause a micro-stutter on the first frame.
This particularly helps when an element is changing `scale`, `rotation`, or moving around with `transform`. For other properties, it doesn't help much — the browser can't composite them on the GPU anyway.
### Rules
```css
/* Good — specific property that benefits from GPU compositing */
.animated-card {
will-change: transform;
}
/* Good — multiple compositor-friendly properties */
.animated-card {
will-change: transform, opacity;
}
/* Bad — never use will-change: all */
.animated-card {
will-change: all;
}
/* Bad — properties that can't be GPU-composited anyway */
.animated-card {
will-change: background-color, padding;
}
```
### Useful Properties
| Property | GPU-compositable | Worth using `will-change` |
| --- | --- | --- |
| `transform` | Yes | Yes |
| `opacity` | Yes | Yes |
| `filter` (blur, brightness) | Yes | Yes |
| `clip-path` | Newer Chromium only | Rarely; not reliable cross-browser |
| `top`, `left`, `width`, `height` | No | No |
| `background`, `border`, `color` | No | No |
### When to Skip
Modern browsers are already good at optimizing on their own. Only add `will-change` when you notice first-frame stutter — Safari in particular benefits from it. Don't add it preemptively to every animated element; each extra compositing layer costs memory.

View File

@@ -0,0 +1,256 @@
# Surfaces
Border radius, optical alignment, shadows, and image outlines.
## Concentric Border Radius
When nesting rounded elements, the outer radius must equal the inner radius plus the padding between them:
```
outerRadius = innerRadius + padding
```
This rule is most useful when nested surfaces are close together. If padding is larger than `24px`, treat the layers as separate surfaces and choose each radius independently instead of forcing strict concentric math.
### Example
```css
/* Good — concentric radii */
.card {
border-radius: 20px; /* 12 + 8 */
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
/* Bad — same radius on both */
.card {
border-radius: 12px;
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
```
### Tailwind Example
```tsx
// Good — outer radius accounts for padding
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
...
</div>
</div>
// Bad — same radius on both
<div className="rounded-xl p-2">
<div className="rounded-xl"> {/* same radius, looks off */}
...
</div>
</div>
```
Mismatched border radii on nested elements is one of the most common things that makes interfaces feel off. Always calculate concentrically.
## Optical Alignment
When geometric centering looks off, align optically instead.
### Buttons with Text + Icon
Use slightly less padding on the icon side to make the button feel balanced. A reliable rule of thumb is:
`icon-side padding = text-side padding - 2px`.
```css
/* Good — less padding on icon side */
.button-with-icon {
padding-left: 16px;
padding-right: 14px; /* icon side = text side - 2px */
}
/* Bad — equal padding looks like icon is pushed too far right */
.button-with-icon {
padding: 0 16px;
}
```
```tsx
// Tailwind
<button className="pl-4 pr-3.5 flex items-center gap-2">
<span>Continue</span>
<ArrowRightIcon />
</button>
```
### Play Button Triangles
Play icons are triangular and their geometric center is not their visual center. Shift slightly right:
```css
/* Good — optically centered */
.play-button svg {
margin-left: 2px; /* shift right to account for triangle shape */
}
/* Bad — geometrically centered but looks off */
.play-button svg {
/* no adjustment */
}
```
### Asymmetric Icons (Stars, Arrows, Carets)
Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code.
```tsx
// Best — fix in the SVG itself
// Adjust the viewBox or path to visually center the icon
// Fallback — adjust with margin
<span className="ml-px">
<StarIcon />
</span>
```
## Shadows Instead of Borders
For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for.
**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders.
### Shadow as Border (Light Mode)
The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth:
```css
:root {
--shadow-border:
0px 0px 0px 1px oklch(0 0 0 / 0.06),
0px 1px 2px -1px oklch(0 0 0 / 0.06),
0px 2px 4px 0px oklch(0 0 0 / 0.04);
--shadow-border-hover:
0px 0px 0px 1px oklch(0 0 0 / 0.08),
0px 1px 2px -1px oklch(0 0 0 / 0.08),
0px 2px 4px 0px oklch(0 0 0 / 0.06);
}
```
### Shadow as Border (Dark Mode)
In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds:
```css
/* Dark mode — adapt to whatever setup the project uses
(prefers-color-scheme, class, data attribute, etc.) */
--shadow-border: 0 0 0 1px oklch(1 0 0 / 0.08);
--shadow-border-hover: 0 0 0 1px oklch(1 0 0 / 0.13);
```
### Usage with Hover Transition
Apply the variable and add `transition-[box-shadow]` for a smooth hover:
```css
.card {
box-shadow: var(--shadow-border);
transition-property: box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.card:hover {
box-shadow: var(--shadow-border-hover);
}
```
### When to Use Shadows vs. Borders
| Use shadows | Use borders |
| --- | --- |
| Cards, containers with depth | Dividers between list items |
| Buttons with bordered styles | Table cell boundaries |
| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) |
| Elements on varied backgrounds | Hairline separators in dense UI |
| Hover/focus states for lift effect | |
## Image Outlines
Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows.
### Color rules (non-negotiable)
- **Light mode**: pure black, `oklch(0 0 0 / 0.1)`.
- **Dark mode**: pure white, `oklch(1 0 0 / 0.1)`.
- Never use a near-black or near-white from the project palette (e.g. slate-900, zinc-900, `#0a0a0a`, `#111827`, `#f5f5f7`). Tinted outlines pick up the surrounding surface color and read as dirt on the image edge.
- Never match the outline to the project's accent or ink color. The outline is a neutral separator, not a themed element.
### Light Mode
```css
img {
outline: 1px solid oklch(0 0 0 / 0.1);
outline-offset: -1px; /* inset so it doesn't add to layout */
}
```
### Dark Mode
```css
img {
outline: 1px solid oklch(1 0 0 / 0.1);
outline-offset: -1px;
}
```
### Tailwind with Dark Mode
```tsx
<img
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
src={src}
alt={alt}
/>
```
Use `outline-black/10` and `outline-white/10` specifically — not `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*`, or any tinted scale.
**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size.
## Minimum Hit Area
Interactive elements should prefer a 44×44px hit area for touch or mobile contexts. In dense desktop interfaces, use at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element.
### CSS Example
```css
/* Small checkbox with expanded 44px hit area */
.checkbox {
position: relative;
width: 20px;
height: 20px;
}
.checkbox::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
}
```
### Tailwind Example
```tsx
<button className="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-11 after:-translate-1/2">
<CheckIcon />
</button>
```
### Collision Rule
If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas.

View File

@@ -0,0 +1,157 @@
# Typography
Typography rendering details that make interfaces feel better.
## Text Wrapping
### text-wrap: balance
Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text.
```css
/* Good — even line lengths on short text */
h1, h2, h3 {
text-wrap: balance;
}
```
```css
/* Bad — default wrapping leaves orphans */
h1 {
/* no text-wrap rule → "Read our
blog" instead of balanced lines */
}
```
```css
/* Bad — balance on long paragraphs (silently ignored, wastes intent) */
.article-body p {
text-wrap: balance;
}
```
**Tailwind:** `text-balance`
### text-wrap: pretty
Prevents orphaned words (a single word dangling on the last line) by adjusting line breaks throughout the paragraph. Unlike `balance`, it doesn't try to equalize line lengths — it just ensures the last line isn't embarrassingly short. Works on text of any length with no line-count limit.
This should be your **default for short-to-medium text** — paragraphs, descriptions, captions, list items, card text. For very long text (10+ lines), skip both `pretty` and `balance` — the browser's default wrapping is fine and you avoid unnecessary layout cost.
```css
/* Good — descriptions, captions, short paragraphs */
p, li, figcaption, blockquote {
text-wrap: pretty;
}
```
```tsx
// Tailwind
<p className="text-pretty">
A short paragraph that won't leave an orphan on the last line.
</p>
```
**Tailwind:** `text-pretty`
### When to Use Which
| Scenario | Use |
| --- | --- |
| Headings, titles where even distribution matters | `text-wrap: balance` |
| Short-to-medium text — paragraphs, descriptions, captions, UI text | `text-wrap: pretty` |
| Long text (10+ lines), code blocks, pre-formatted text | Neither — leave default |
## Font Smoothing (macOS)
On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner.
```css
/* CSS */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
```
```tsx
// Tailwind — apply to root layout
<html className="antialiased">
```
### Good vs. Bad
```css
/* Good — applied once at the root */
html {
-webkit-font-smoothing: antialiased;
}
/* Bad — applied per-element, inconsistent */
.heading {
-webkit-font-smoothing: antialiased;
}
.body {
/* no smoothing → heavier than heading */
}
```
**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally.
## Font Family Scope
This skill does not require a specific font family. Do not introduce a paid or proprietary typeface just to satisfy the polish checklist.
Use the product's existing type system unless the task explicitly asks for a type change. If the design calls for a system-native macOS feel, use the system font stack. If the design calls for a commercial face such as Helvetica Now, treat it as an optional brand decision and keep a practical fallback stack.
```css
/* System-native macOS/iOS feel */
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
```
```css
/* Commercial brand face with safe fallbacks */
html {
font-family: "Helvetica Now", "Helvetica Neue", Arial, sans-serif;
}
```
**Rule:** font smoothing, text wrapping, and tabular numbers are rendering details. They do not override the project's chosen font family.
## Tabular Numbers
When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change.
```css
/* CSS */
.counter {
font-variant-numeric: tabular-nums;
}
```
```tsx
// Tailwind
<span className="tabular-nums">{count}</span>
```
### When to Use
| Use tabular-nums | Don't use tabular-nums |
| --- | --- |
| Counters and timers | Static display numbers |
| Prices that update | Decorative large numbers |
| Table columns with numbers | Phone numbers, zip codes |
| Animated number transitions | Version numbers (v2.1.0) |
| Scoreboards, dashboards | |
### Caveat
Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font.
```css
/* With Inter font:
Default: 1234 → proportional, "1" is narrow
Tabular: 1234 → all digits equal width, "1" centered */
```

View File

@@ -0,0 +1,216 @@
---
name: prisma-client-api
description: Prisma Client API reference covering model queries, filters, operators, and client methods. Use when writing database queries, using CRUD operations, filtering data, or configuring Prisma Client. Triggers on "prisma query", "findMany", "create", "update", "delete", "$transaction".
license: MIT
metadata:
author: prisma
version: "7.9.1"
---
# Prisma Client API Reference
Complete API reference for Prisma Client. This skill provides guidance on model queries, filtering, relations, and client methods for current Prisma projects.
## When to Apply
Reference this skill when:
- Writing database queries with Prisma Client
- Performing CRUD operations (create, read, update, delete)
- Filtering and sorting data
- Working with relations
- Using transactions
- Configuring client options
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Client Construction | HIGH | `constructor` |
| 2 | Model Queries | CRITICAL | `model-queries` |
| 3 | Query Shape | HIGH | `query-options` |
| 4 | Filtering | HIGH | `filters` |
| 5 | Relations | HIGH | `relations` |
| 6 | Transactions | CRITICAL | `transactions` |
| 7 | Raw SQL | CRITICAL | `raw-queries` |
| 8 | Client Methods | MEDIUM | `client-methods` |
## Quick Reference
- `constructor` - `PrismaClient` setup, adapter wiring, logging, and SQL commenter plugins
- `model-queries` - CRUD operations and bulk operations
- `query-options` - `select`, `include`, `omit`, sort, pagination
- `filters` - scalar and logical filter operators
- `relations` - relation reads and nested writes
- `transactions` - array and interactive transaction patterns
- `raw-queries` - `$queryRaw` and `$executeRaw` safety
- `client-methods` - lifecycle methods, extensions, and `satisfies` patterns for `prisma-client`
## Client Instantiation
```typescript
import { PrismaClient } from '../generated/client'
import { PrismaPg } from '@prisma/adapter-pg'
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
})
const prisma = new PrismaClient({ adapter })
```
## Model Query Methods
| Method | Description |
|--------|-------------|
| `findUnique()` | Find one record by unique field |
| `findUniqueOrThrow()` | Find one or throw error |
| `findFirst()` | Find first matching record |
| `findFirstOrThrow()` | Find first or throw error |
| `findMany()` | Find multiple records |
| `create()` | Create a new record |
| `createMany()` | Create multiple records |
| `createManyAndReturn()` | Create multiple and return them |
| `update()` | Update one record |
| `updateMany()` | Update multiple records |
| `updateManyAndReturn()` | Update multiple and return them |
| `upsert()` | Update or create record |
| `delete()` | Delete one record |
| `deleteMany()` | Delete multiple records |
| `count()` | Count matching records |
| `aggregate()` | Aggregate values (sum, avg, etc.) |
| `groupBy()` | Group and aggregate |
## Query Options
| Option | Description |
|--------|-------------|
| `where` | Filter conditions |
| `select` | Fields to include |
| `include` | Relations to load |
| `omit` | Fields to exclude |
| `orderBy` | Sort order |
| `take` | Limit results |
| `skip` | Skip results (pagination) |
| `cursor` | Cursor-based pagination |
| `distinct` | Unique values only |
## Client Methods
| Method | Description |
|--------|-------------|
| `$connect()` | Explicitly connect to database |
| `$disconnect()` | Disconnect from database |
| `$transaction()` | Execute transaction |
| `$queryRaw()` | Execute raw SQL query |
| `$executeRaw()` | Execute raw SQL command |
| `$on()` | Subscribe to events |
| `$extends()` | Add extensions |
## Quick Examples
### Find records
```typescript
// Find by unique field
const user = await prisma.user.findUnique({
where: { email: 'alice@prisma.io' }
})
// Find with filter
const users = await prisma.user.findMany({
where: { role: 'ADMIN' },
orderBy: { createdAt: 'desc' },
take: 10
})
```
### Create records
```typescript
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
name: 'Alice',
posts: {
create: { title: 'Hello World' }
}
},
include: { posts: true }
})
```
### Update records
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: { name: 'Alice Smith' }
})
```
### Delete records
```typescript
await prisma.user.delete({
where: { id: 1 }
})
```
### Transactions
```typescript
const [user, post] = await prisma.$transaction([
prisma.user.create({ data: { email: 'alice@prisma.io' } }),
prisma.post.create({ data: { title: 'Hello', authorId: 1 } })
])
```
## Rule Files
Detailed API documentation:
```
references/constructor.md - PrismaClient constructor options
references/model-queries.md - CRUD operations
references/query-options.md - select, include, omit, where, orderBy
references/filters.md - Filter conditions and operators
references/relations.md - Relation queries and nested operations
references/transactions.md - Transaction API
references/raw-queries.md - $queryRaw, $executeRaw
references/client-methods.md - $connect, $disconnect, $on, $extends
```
## Filter Operators
| Operator | Description |
|----------|-------------|
| `equals` | Exact match |
| `not` | Not equal |
| `in` | In array |
| `notIn` | Not in array |
| `lt`, `lte` | Less than |
| `gt`, `gte` | Greater than |
| `contains` | String contains |
| `startsWith` | String starts with |
| `endsWith` | String ends with |
| `mode` | Case sensitivity |
## Relation Filters
| Operator | Description |
|----------|-------------|
| `some` | At least one related record matches |
| `every` | All related records match |
| `none` | No related records match |
| `is` | Related record matches (1-to-1) |
| `isNot` | Related record doesn't match |
## Resources
- [Prisma Client API Reference](https://www.prisma.io/docs/orm/reference/prisma-client-reference)
- [CRUD Operations](https://www.prisma.io/docs/orm/prisma-client/queries/crud)
- [Filtering and Sorting](https://www.prisma.io/docs/orm/prisma-client/queries/filtering-and-sorting)
## How to Use
Pick the category from the table above, then open the matching reference file for implementation details and examples.

View File

@@ -0,0 +1,223 @@
# Client Methods
Prisma Client instance methods.
## $connect()
Explicitly connect to the database:
```typescript
const prisma = new PrismaClient({ adapter })
// Explicit connection
await prisma.$connect()
```
### When to use
Usually not needed - Prisma connects automatically on first query. Use for:
- Fail fast on startup
- Health checks
- Pre-warming connections
```typescript
async function main() {
try {
await prisma.$connect()
console.log('Database connected')
} catch (e) {
console.error('Failed to connect:', e)
process.exit(1)
}
}
```
## $disconnect()
Close database connection:
```typescript
await prisma.$disconnect()
```
### Graceful shutdown
```typescript
process.on('beforeExit', async () => {
await prisma.$disconnect()
})
// Or with SIGTERM
process.on('SIGTERM', async () => {
await prisma.$disconnect()
process.exit(0)
})
```
### In tests
```typescript
afterAll(async () => {
await prisma.$disconnect()
})
```
## $on()
Subscribe to events:
### Query events
```typescript
const prisma = new PrismaClient({
adapter,
log: [{ level: 'query', emit: 'event' }]
})
prisma.$on('query', (e) => {
console.log('Query:', e.query)
console.log('Params:', e.params)
console.log('Duration:', e.duration, 'ms')
})
```
### Log events
```typescript
const prisma = new PrismaClient({
adapter,
log: [
{ level: 'info', emit: 'event' },
{ level: 'warn', emit: 'event' },
{ level: 'error', emit: 'event' }
]
})
prisma.$on('info', (e) => console.log(e.message))
prisma.$on('warn', (e) => console.warn(e.message))
prisma.$on('error', (e) => console.error(e.message))
```
## $extends()
Add extensions for custom behavior:
### Add custom methods
```typescript
const prisma = new PrismaClient({ adapter }).$extends({
client: {
$log: (message: string) => console.log(message)
}
})
prisma.$log('Hello!')
```
### Add model methods
```typescript
const prisma = new PrismaClient({ adapter }).$extends({
model: {
user: {
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email } })
}
}
}
})
const user = await prisma.user.findByEmail('alice@prisma.io')
```
### Query extensions
```typescript
const prisma = new PrismaClient({ adapter }).$extends({
query: {
user: {
async findMany({ args, query }) {
// Add default filter
args.where = { ...args.where, deletedAt: null }
return query(args)
}
}
}
})
```
### Result extensions
```typescript
const prisma = new PrismaClient({ adapter }).$extends({
result: {
user: {
fullName: {
needs: { firstName: true, lastName: true },
compute(user) {
return `${user.firstName} ${user.lastName}`
}
}
}
}
})
const user = await prisma.user.findFirst()
console.log(user.fullName) // Computed field
```
### Chain extensions
```typescript
const prisma = new PrismaClient({ adapter })
.$extends(loggingExtension)
.$extends(softDeleteExtension)
.$extends(computedFieldsExtension)
```
## $transaction()
See `transactions.md` for details.
## $queryRaw() / $executeRaw()
See `raw-queries.md` for details.
## Type utilities
### Prisma namespace
```typescript
import { Prisma } from '../generated/client'
// Input types
type UserCreateInput = Prisma.UserCreateInput
type UserWhereInput = Prisma.UserWhereInput
// Output types
type User = Prisma.UserGetPayload<{}>
type UserWithPosts = Prisma.UserGetPayload<{
include: { posts: true }
}>
```
### Type-safe query fragments with satisfies
Type-safe query fragments:
```typescript
import { Prisma } from '../generated/client'
const userSelect = {
id: true,
email: true,
name: true
} satisfies Prisma.UserSelect
const user = await prisma.user.findUnique({
where: { id: 1 },
select: userSelect
})
```
With the `prisma-client` generator, use TypeScript `satisfies` for typed query fragments. You may still see older examples that use `Prisma.validator()` with `prisma-client-js`.

View File

@@ -0,0 +1,221 @@
# PrismaClient Constructor
Configure Prisma Client when instantiating.
## Basic Instantiation
```typescript
import { PrismaClient } from '../generated/client'
import { PrismaPg } from '@prisma/adapter-pg'
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
})
const prisma = new PrismaClient({ adapter })
```
## Constructor Options
### adapter (Required for the SQL provider workflow)
Driver adapter instance:
```typescript
import { PrismaPg } from '@prisma/adapter-pg'
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
})
const prisma = new PrismaClient({ adapter })
```
### accelerateUrl (For Accelerate users)
```typescript
import { withAccelerate } from '@prisma/extension-accelerate'
const prisma = new PrismaClient({
accelerateUrl: process.env.DATABASE_URL, // prisma:// URL
}).$extends(withAccelerate())
```
### log
Configure logging:
```typescript
const prisma = new PrismaClient({
adapter,
log: ['query', 'info', 'warn', 'error'],
})
```
#### Log levels
| Level | Description |
|-------|-------------|
| `query` | All SQL queries |
| `info` | Informational messages |
| `warn` | Warnings |
| `error` | Errors |
#### Log to events
```typescript
const prisma = new PrismaClient({
adapter,
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
],
})
prisma.$on('query', (e) => {
console.log('Query:', e.query)
console.log('Duration:', e.duration, 'ms')
})
```
### errorFormat
Control error formatting:
```typescript
const prisma = new PrismaClient({
adapter,
errorFormat: 'pretty', // 'pretty' | 'colorless' | 'minimal'
})
```
### comments
Attach SQL commenter plugins for observability, tracing, or query insights:
```typescript
import { PrismaClient } from '../generated/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { prismaQueryInsights } from '@prisma/sqlcommenter-query-insights'
import { queryTags, withQueryTags } from '@prisma/sqlcommenter-query-tags'
import { traceContext } from '@prisma/sqlcommenter-trace-context'
const prisma = new PrismaClient({
adapter: new PrismaPg(process.env.DATABASE_URL!),
comments: [prismaQueryInsights(), traceContext(), queryTags()],
})
await withQueryTags({ route: '/api/users', requestId: 'req-123' }, () =>
prisma.user.findMany(),
)
```
Use `comments` only for SQL providers. This is the clean way to add trace or query-shape metadata without changing your query calls.
### transactionOptions
Default transaction settings:
```typescript
const prisma = new PrismaClient({
adapter,
transactionOptions: {
maxWait: 5000, // Max wait to acquire transaction (ms)
timeout: 10000, // Max transaction duration (ms)
isolationLevel: 'Serializable',
},
})
```
### queryPlanCacheMaxSize
Use `queryPlanCacheMaxSize` to limit the in-memory query-plan cache:
```typescript
const prisma = new PrismaClient({
adapter,
queryPlanCacheMaxSize: 2_000,
})
```
The value must be a non-negative integer. Set it to `0` to disable query-plan caching; omit it to use Prisma's default. Treat this as a process-local memory/performance control, not a database prepared-statement setting.
## Singleton Pattern
Prevent multiple client instances in development:
```typescript
// lib/prisma.ts
import { PrismaClient } from '../generated/client'
import { PrismaPg } from '@prisma/adapter-pg'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!
})
return new PrismaClient({ adapter })
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
```
## Next.js Pattern
```typescript
// lib/prisma.ts
import { PrismaClient } from '@/generated/client'
import { PrismaPg } from '@prisma/adapter-pg'
const createAdapter = () => new PrismaPg({
connectionString: process.env.DATABASE_URL!
})
const prismaClientSingleton = () => {
return new PrismaClient({ adapter: createAdapter() })
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>
} & typeof global
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') {
globalThis.prismaGlobal = prisma
}
```
## Query Events
Listen to query events:
```typescript
const prisma = new PrismaClient({
adapter,
log: [{ level: 'query', emit: 'event' }],
})
prisma.$on('query', (e) => {
console.log('Query:', e.query)
console.log('Params:', e.params)
console.log('Duration:', e.duration)
})
```
## Log Events
```typescript
prisma.$on('info', (e) => console.log(e.message))
prisma.$on('warn', (e) => console.warn(e.message))
prisma.$on('error', (e) => console.error(e.message))
```

View File

@@ -0,0 +1,256 @@
# Filter Conditions and Operators
Filter operators for the `where` clause.
## Equality
```typescript
// Exact match (implicit)
where: { email: 'alice@prisma.io' }
// Explicit equals
where: { email: { equals: 'alice@prisma.io' } }
// Not equal
where: { email: { not: 'alice@prisma.io' } }
```
## Comparison
```typescript
// Greater than
where: { age: { gt: 18 } }
// Greater than or equal
where: { age: { gte: 18 } }
// Less than
where: { age: { lt: 65 } }
// Less than or equal
where: { age: { lte: 65 } }
// Combined
where: { age: { gte: 18, lte: 65 } }
```
## Lists
```typescript
// In array
where: { role: { in: ['ADMIN', 'MODERATOR'] } }
// Not in array
where: { role: { notIn: ['GUEST', 'BANNED'] } }
```
## String Filters
```typescript
// Contains
where: { email: { contains: 'prisma' } }
// Starts with
where: { email: { startsWith: 'alice' } }
// Ends with
where: { email: { endsWith: '@prisma.io' } }
// Case-insensitive (default for some databases)
where: {
email: {
contains: 'PRISMA',
mode: 'insensitive'
}
}
```
## Null Checks
```typescript
// Is null
where: { deletedAt: null }
// Is not null
where: { deletedAt: { not: null } }
// Using isSet (for optional fields)
where: { middleName: { isSet: true } }
```
## Logical Operators
### AND (implicit)
```typescript
// Multiple conditions = AND
where: {
email: { contains: '@prisma.io' },
role: 'ADMIN'
}
```
### AND (explicit)
```typescript
where: {
AND: [
{ email: { contains: '@prisma.io' } },
{ role: 'ADMIN' }
]
}
```
### OR
```typescript
where: {
OR: [
{ email: { contains: '@gmail.com' } },
{ email: { contains: '@prisma.io' } }
]
}
```
### NOT
```typescript
where: {
NOT: {
role: 'GUEST'
}
}
// Multiple NOT conditions
where: {
NOT: [
{ role: 'GUEST' },
{ verified: false }
]
}
```
### Combined
```typescript
where: {
AND: [
{ verified: true },
{
OR: [
{ role: 'ADMIN' },
{ role: 'MODERATOR' }
]
}
],
NOT: { deletedAt: { not: null } }
}
```
## Relation Filters
### some
At least one related record matches:
```typescript
// Users with at least one published post
where: {
posts: {
some: { published: true }
}
}
```
### every
All related records match:
```typescript
// Users where all posts are published
where: {
posts: {
every: { published: true }
}
}
```
### none
No related records match:
```typescript
// Users with no published posts
where: {
posts: {
none: { published: true }
}
}
```
### is / isNot (1-to-1)
```typescript
// Users with profile in specific country
where: {
profile: {
is: { country: 'USA' }
}
}
// Users without profile
where: {
profile: {
isNot: null
}
}
```
## Array Field Filters
For fields like `String[]`:
```typescript
// Has element
where: { tags: { has: 'typescript' } }
// Has some elements
where: { tags: { hasSome: ['typescript', 'javascript'] } }
// Has every element
where: { tags: { hasEvery: ['typescript', 'prisma'] } }
// Is empty
where: { tags: { isEmpty: true } }
```
## JSON Filters
```typescript
// Path-based filter
where: {
metadata: {
path: ['settings', 'theme'],
equals: 'dark'
}
}
// String contains in JSON
where: {
metadata: {
path: ['bio'],
string_contains: 'developer'
}
}
```
## Full-Text Search
```typescript
// Requires @@fulltext index
where: {
content: {
search: 'prisma database'
}
}
```

View File

@@ -0,0 +1,281 @@
# Model Queries
CRUD operations for your Prisma models.
## Read Operations
### findUnique
Find a single record by unique field:
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 }
})
const user = await prisma.user.findUnique({
where: { email: 'alice@prisma.io' }
})
```
#### With composite unique key
```typescript
// Model with @@unique([firstName, lastName])
const user = await prisma.user.findUnique({
where: {
firstName_lastName: {
firstName: 'Alice',
lastName: 'Smith'
}
}
})
```
### findUniqueOrThrow
Same as findUnique but throws if not found:
```typescript
const user = await prisma.user.findUniqueOrThrow({
where: { id: 1 }
})
// Throws PrismaClientKnownRequestError if not found
```
### findFirst
Find first matching record:
```typescript
const user = await prisma.user.findFirst({
where: { role: 'ADMIN' },
orderBy: { createdAt: 'desc' }
})
```
### findFirstOrThrow
```typescript
const user = await prisma.user.findFirstOrThrow({
where: { role: 'ADMIN' }
})
```
### findMany
Find multiple records:
```typescript
const users = await prisma.user.findMany({
where: { role: 'USER' },
orderBy: { name: 'asc' },
take: 10,
skip: 0
})
```
## Create Operations
### create
Create a single record:
```typescript
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
name: 'Alice'
}
})
```
#### With relations
```typescript
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
posts: {
create: [
{ title: 'First Post' },
{ title: 'Second Post' }
]
}
},
include: { posts: true }
})
```
### createMany
Create multiple records:
```typescript
const result = await prisma.user.createMany({
data: [
{ email: 'alice@prisma.io', name: 'Alice' },
{ email: 'bob@prisma.io', name: 'Bob' }
],
skipDuplicates: true // Skip records with duplicate unique fields
})
// Returns { count: 2 }
```
### createManyAndReturn
Create multiple and return them:
```typescript
const users = await prisma.user.createManyAndReturn({
data: [
{ email: 'alice@prisma.io', name: 'Alice' },
{ email: 'bob@prisma.io', name: 'Bob' }
]
})
// Returns array of created users
```
## Update Operations
### update
Update a single record:
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: { name: 'Alice Smith' }
})
```
#### Atomic operations
```typescript
const post = await prisma.post.update({
where: { id: 1 },
data: {
views: { increment: 1 },
likes: { decrement: 1 },
score: { multiply: 2 },
rating: { divide: 2 },
version: { set: 5 }
}
})
```
### updateMany
Update multiple records:
```typescript
const result = await prisma.user.updateMany({
where: { role: 'USER' },
data: { verified: true }
})
// Returns { count: 42 }
```
### updateManyAndReturn
```typescript
const users = await prisma.user.updateManyAndReturn({
where: { role: 'USER' },
data: { verified: true }
})
// Returns array of updated users
```
### upsert
Update or create:
```typescript
const user = await prisma.user.upsert({
where: { email: 'alice@prisma.io' },
update: { name: 'Alice Smith' },
create: { email: 'alice@prisma.io', name: 'Alice' }
})
```
## Delete Operations
### delete
Delete a single record:
```typescript
const user = await prisma.user.delete({
where: { id: 1 }
})
// Returns deleted record
```
### deleteMany
Delete multiple records:
```typescript
const result = await prisma.user.deleteMany({
where: { role: 'GUEST' }
})
// Returns { count: 5 }
// Delete all
const result = await prisma.user.deleteMany({})
```
## Aggregation Operations
### count
```typescript
const count = await prisma.user.count({
where: { role: 'ADMIN' }
})
```
### aggregate
```typescript
const result = await prisma.post.aggregate({
_avg: { views: true },
_sum: { views: true },
_min: { views: true },
_max: { views: true },
_count: { _all: true }
})
```
### groupBy
```typescript
const groups = await prisma.user.groupBy({
by: ['country'],
_count: { _all: true },
_avg: { age: true },
having: {
age: { _avg: { gt: 30 } }
}
})
```
## Return Types
| Method | Returns |
|--------|---------|
| `findUnique` | Record \| null |
| `findUniqueOrThrow` | Record (throws if not found) |
| `findFirst` | Record \| null |
| `findFirstOrThrow` | Record (throws if not found) |
| `findMany` | Record[] |
| `create` | Record |
| `createMany` | { count: number } |
| `createManyAndReturn` | Record[] |
| `update` | Record |
| `updateMany` | { count: number } |
| `delete` | Record |
| `deleteMany` | { count: number } |
| `count` | number |
| `aggregate` | Aggregate result |
| `groupBy` | Group result[] |

View File

@@ -0,0 +1,276 @@
# Query Options
Options for controlling query behavior.
## select
Choose specific fields to return:
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
select: {
id: true,
name: true,
email: true,
// password: false (excluded by not including)
}
})
// Returns: { id: 1, name: 'Alice', email: 'alice@prisma.io' }
```
### Select relations
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
select: {
name: true,
posts: {
select: {
title: true,
published: true
}
}
}
})
```
### Select with include inside
```typescript
const user = await prisma.user.findMany({
select: {
name: true,
posts: {
include: {
comments: true
}
}
}
})
```
### Select relation count
```typescript
const users = await prisma.user.findMany({
select: {
name: true,
_count: {
select: { posts: true }
}
}
})
// Returns: { name: 'Alice', _count: { posts: 5 } }
```
## include
Include related records:
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true,
profile: true
}
})
```
### Filtered include
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 5
}
}
})
```
### Nested include
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
include: {
comments: {
include: {
author: true
}
}
}
}
}
})
```
### Include relation count
```typescript
const users = await prisma.user.findMany({
include: {
_count: {
select: { posts: true, followers: true }
}
}
})
```
## omit
Exclude specific fields:
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
omit: {
password: true
}
})
// Returns all fields except password
```
### Omit in relations
```typescript
const users = await prisma.user.findMany({
omit: { password: true },
include: {
posts: {
omit: { content: true }
}
}
})
```
**Note:** Cannot use `select` and `omit` together.
## where
Filter records:
```typescript
const users = await prisma.user.findMany({
where: {
email: { contains: '@prisma.io' },
role: 'ADMIN'
}
})
```
See `filters.md` for detailed filter operators.
## orderBy
Sort results:
```typescript
// Single field
const users = await prisma.user.findMany({
orderBy: { name: 'asc' }
})
// Multiple fields
const users = await prisma.user.findMany({
orderBy: [
{ role: 'desc' },
{ name: 'asc' }
]
})
```
### Order by relation
```typescript
const users = await prisma.user.findMany({
orderBy: {
posts: { _count: 'desc' }
}
})
```
### Null handling
```typescript
const users = await prisma.user.findMany({
orderBy: {
name: { sort: 'asc', nulls: 'last' }
}
})
```
## take & skip
Pagination:
```typescript
// First page
const users = await prisma.user.findMany({
take: 10,
skip: 0
})
// Second page
const users = await prisma.user.findMany({
take: 10,
skip: 10
})
```
### Negative take (reverse)
```typescript
const lastUsers = await prisma.user.findMany({
take: -10,
orderBy: { id: 'asc' }
})
// Returns last 10 users
```
## cursor
Cursor-based pagination:
```typescript
// First page
const firstPage = await prisma.user.findMany({
take: 10,
orderBy: { id: 'asc' }
})
// Next page using cursor
const nextPage = await prisma.user.findMany({
take: 10,
skip: 1, // Skip the cursor record
cursor: { id: firstPage[firstPage.length - 1].id },
orderBy: { id: 'asc' }
})
```
## distinct
Return unique values:
```typescript
const cities = await prisma.user.findMany({
distinct: ['city'],
select: { city: true }
})
```
### Multiple distinct fields
```typescript
const locations = await prisma.user.findMany({
distinct: ['city', 'country']
})
```

View File

@@ -0,0 +1,198 @@
# Raw Queries
Execute raw SQL when Prisma's query API isn't sufficient.
## $queryRaw
Execute SELECT queries and get typed results:
```typescript
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE email LIKE ${'%@prisma.io'}
`
```
### With type
```typescript
type User = { id: number; email: string; name: string | null }
const users = await prisma.$queryRaw<User[]>`
SELECT id, email, name FROM "User" WHERE role = ${'ADMIN'}
`
```
### Dynamic table/column names
Use `Prisma.raw()` for identifiers (not safe for user input):
```typescript
import { Prisma } from '../generated/client'
const column = 'email'
const users = await prisma.$queryRaw`
SELECT ${Prisma.raw(column)} FROM "User"
`
```
### With Prisma.sql
Build queries dynamically:
```typescript
import { Prisma } from '../generated/client'
const email = 'alice@prisma.io'
const query = Prisma.sql`SELECT * FROM "User" WHERE email = ${email}`
const users = await prisma.$queryRaw(query)
```
### Join multiple SQL fragments
```typescript
import { Prisma } from '../generated/client'
const conditions = [
Prisma.sql`role = ${'ADMIN'}`,
Prisma.sql`verified = ${true}`
]
const users = await prisma.$queryRaw`
SELECT * FROM "User"
WHERE ${Prisma.join(conditions, ' AND ')}
`
```
## $executeRaw
Execute INSERT, UPDATE, DELETE (returns affected count):
```typescript
const count = await prisma.$executeRaw`
UPDATE "User" SET verified = true WHERE email LIKE ${'%@prisma.io'}
`
console.log(`Updated ${count} users`)
```
### Delete example
```typescript
const deleted = await prisma.$executeRaw`
DELETE FROM "User" WHERE "deletedAt" < ${thirtyDaysAgo}
`
```
### Insert example
```typescript
const inserted = await prisma.$executeRaw`
INSERT INTO "Log" (message, level, timestamp)
VALUES (${message}, ${level}, ${new Date()})
`
```
## $queryRawUnsafe / $executeRawUnsafe
For fully dynamic queries (use with caution!):
```typescript
// ⚠️ SQL injection risk - only use with trusted input
const table = 'User'
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM "${table}" WHERE id = $1`,
userId
)
```
### Parameterized unsafe query
```typescript
const result = await prisma.$executeRawUnsafe(
'UPDATE "User" SET name = $1 WHERE id = $2',
'Alice',
1
)
```
## SQL Injection Prevention
### Safe (parameterized)
```typescript
// ✅ User input is parameterized
const email = userInput
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE email = ${email}
`
```
### Unsafe (concatenation)
```typescript
// ❌ SQL injection vulnerability!
const email = userInput
const users = await prisma.$queryRawUnsafe(
`SELECT * FROM "User" WHERE email = '${email}'`
)
```
## Database-Specific Features
### PostgreSQL
```typescript
// Array operations
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE 'admin' = ANY(roles)
`
// JSON operations
const users = await prisma.$queryRaw`
SELECT * FROM "User" WHERE metadata->>'theme' = 'dark'
`
```
### MySQL
```typescript
// Full-text search
const posts = await prisma.$queryRaw`
SELECT * FROM Post WHERE MATCH(title, content) AGAINST(${searchTerm})
`
```
## Transactions with Raw Queries
```typescript
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`UPDATE "Account" SET balance = balance - ${amount} WHERE id = ${senderId}`
await tx.$executeRaw`UPDATE "Account" SET balance = balance + ${amount} WHERE id = ${recipientId}`
})
```
## Handling Results
### BigInt handling
PostgreSQL returns BigInt for COUNT:
```typescript
const result = await prisma.$queryRaw<[{ count: bigint }]>`
SELECT COUNT(*) as count FROM "User"
`
const count = Number(result[0].count)
```
### Date handling
```typescript
type Result = { createdAt: Date }
const users = await prisma.$queryRaw<Result[]>`
SELECT "createdAt" FROM "User"
`
// createdAt is already a Date object
```
Invalid JavaScript `Date` values passed to raw queries fail validation instead of being silently serialized as `null`. Validate date input at the application boundary; do not rely on `new Date(badValue)` reaching the database.
When a driver adapter returns an unmapped database-specific error, Prisma surfaces `P2039` with the adapter's preserved original code/message. If those details are missing, fix the adapter mapping rather than parsing rendered error text.

View File

@@ -0,0 +1,308 @@
# Relation Queries
Query and modify related records.
## Include Relations
Load related records:
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true,
profile: true
}
})
```
### Filtered include
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 5,
select: { id: true, title: true }
}
}
})
```
### Nested include
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
include: {
comments: {
include: { author: true }
}
}
}
}
})
```
## Select Relations
```typescript
const user = await prisma.user.findUnique({
where: { id: 1 },
select: {
name: true,
posts: {
select: { title: true }
}
}
})
```
## Nested Writes
### Create with relations
```typescript
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
posts: {
create: [
{ title: 'Post 1' },
{ title: 'Post 2' }
]
},
profile: {
create: { bio: 'Hello!' }
}
}
})
```
### Create or connect
```typescript
const post = await prisma.post.create({
data: {
title: 'New Post',
author: {
connectOrCreate: {
where: { email: 'alice@prisma.io' },
create: { email: 'alice@prisma.io', name: 'Alice' }
}
}
}
})
```
### Connect existing
```typescript
const post = await prisma.post.create({
data: {
title: 'New Post',
author: {
connect: { id: 1 }
}
}
})
// Shorthand for foreign key
const post = await prisma.post.create({
data: {
title: 'New Post',
authorId: 1
}
})
```
## Update Relations
### Update related records
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: {
posts: {
update: {
where: { id: 1 },
data: { title: 'Updated Title' }
}
}
}
})
```
### Update many related
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: {
posts: {
updateMany: {
where: { published: false },
data: { published: true }
}
}
}
})
```
### Upsert related
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: {
profile: {
upsert: {
create: { bio: 'New bio' },
update: { bio: 'Updated bio' }
}
}
}
})
```
### Disconnect
```typescript
// 1-to-1 optional
const user = await prisma.user.update({
where: { id: 1 },
data: {
profile: { disconnect: true }
}
})
// Many-to-many
const post = await prisma.post.update({
where: { id: 1 },
data: {
tags: {
disconnect: [{ id: 1 }, { id: 2 }]
}
}
})
```
### Delete related
```typescript
const user = await prisma.user.update({
where: { id: 1 },
data: {
posts: {
delete: { id: 1 }
}
}
})
// Delete many
const user = await prisma.user.update({
where: { id: 1 },
data: {
posts: {
deleteMany: { published: false }
}
}
})
```
### Set (replace all)
```typescript
// Replace all related records
const post = await prisma.post.update({
where: { id: 1 },
data: {
tags: {
set: [{ id: 1 }, { id: 2 }]
}
}
})
```
## Relation Filters
### some
At least one matches:
```typescript
const users = await prisma.user.findMany({
where: {
posts: { some: { published: true } }
}
})
```
### every
All match:
```typescript
const users = await prisma.user.findMany({
where: {
posts: { every: { published: true } }
}
})
```
### none
None match:
```typescript
const users = await prisma.user.findMany({
where: {
posts: { none: { published: true } }
}
})
```
### is / isNot (1-to-1)
```typescript
const users = await prisma.user.findMany({
where: {
profile: { is: { country: 'USA' } }
}
})
```
## Count Relations
```typescript
const users = await prisma.user.findMany({
select: {
name: true,
_count: {
select: { posts: true, followers: true }
}
}
})
// { name: 'Alice', _count: { posts: 5, followers: 100 } }
```
### Filter counted relations
```typescript
const users = await prisma.user.findMany({
select: {
name: true,
_count: {
select: {
posts: { where: { published: true } }
}
}
}
})
```

View File

@@ -0,0 +1,184 @@
# Transactions
Execute multiple operations atomically.
## Sequential Transactions
Array of operations executed in order:
```typescript
const [user, post] = await prisma.$transaction([
prisma.user.create({ data: { email: 'alice@prisma.io' } }),
prisma.post.create({ data: { title: 'Hello', authorId: 1 } })
])
```
### All or nothing
If any operation fails, all are rolled back:
```typescript
try {
await prisma.$transaction([
prisma.user.create({ data: { email: 'alice@prisma.io' } }),
prisma.user.create({ data: { email: 'alice@prisma.io' } }) // Duplicate!
])
} catch (e) {
// Both operations rolled back
}
```
## Interactive Transactions
For complex logic and dependent operations:
```typescript
await prisma.$transaction(async (tx) => {
// Decrement sender balance
const sender = await tx.account.update({
where: { id: senderId },
data: { balance: { decrement: amount } }
})
// Check balance
if (sender.balance < 0) {
throw new Error('Insufficient funds')
}
// Increment recipient balance
await tx.account.update({
where: { id: recipientId },
data: { balance: { increment: amount } }
})
})
```
### Transaction options
```typescript
await prisma.$transaction(
async (tx) => {
// operations
},
{
maxWait: 5000, // Max wait to acquire lock (ms)
timeout: 10000, // Max transaction duration (ms)
isolationLevel: 'Serializable' // Isolation level
}
)
```
### Isolation levels
| Level | Description |
|-------|-------------|
| `ReadUncommitted` | Lowest isolation, can read uncommitted changes |
| `ReadCommitted` | Only read committed changes |
| `RepeatableRead` | Consistent reads within transaction |
| `Serializable` | Highest isolation, serialized execution |
## Nested Writes
Automatic transactions for nested operations:
```typescript
// This is automatically a transaction
const user = await prisma.user.create({
data: {
email: 'alice@prisma.io',
posts: {
create: [
{ title: 'Post 1' },
{ title: 'Post 2' }
]
},
profile: {
create: { bio: 'Hello!' }
}
}
})
```
## Transaction Client
The `tx` parameter is a Prisma Client scoped to the transaction:
```typescript
await prisma.$transaction(async (tx) => {
// Use tx instead of prisma
await tx.user.create({ ... })
await tx.post.create({ ... })
// Can call methods
const count = await tx.user.count()
})
```
## OrThrow in Transactions
Use with interactive transactions:
```typescript
await prisma.$transaction(async (tx) => {
// If not found, throws and rolls back entire transaction
const user = await tx.user.findUniqueOrThrow({
where: { id: 1 }
})
await tx.post.create({
data: { title: 'New Post', authorId: user.id }
})
})
```
## Best Practices
### Keep transactions short
```typescript
// Good - only DB operations in transaction
const data = prepareData() // Outside transaction
await prisma.$transaction(async (tx) => {
await tx.user.create({ data })
})
```
### Handle errors
```typescript
try {
await prisma.$transaction(async (tx) => {
// operations
})
} catch (e) {
if (e.code === 'P2002') {
// Handle unique constraint violation
}
throw e
}
```
### Use appropriate isolation
```typescript
// Default is fine for most cases
await prisma.$transaction(async (tx) => {
// operations
})
// Use Serializable for strict consistency
await prisma.$transaction(
async (tx) => { /* operations */ },
{ isolationLevel: 'Serializable' }
)
```
## Sequential vs Interactive
| Feature | Sequential | Interactive |
|---------|------------|-------------|
| Syntax | Array | Async function |
| Dependent ops | No | Yes |
| Conditional logic | No | Yes |
| Performance | Better | More flexible |
| Use case | Simple batch | Complex logic |

View File

@@ -0,0 +1,145 @@
---
name: prisma-postgres
description: Prisma Postgres setup and operations guidance across Console, create-db CLI, Management API, and Management API SDK. Use when creating Prisma Postgres databases, working in Prisma Console, provisioning with create-db/create-pg/create-postgres, or integrating programmatic provisioning with service tokens or OAuth.
license: MIT
metadata:
author: prisma
version: "7.9.1"
---
# Prisma Postgres
Guidance for creating, managing, and integrating Prisma Postgres across interactive and programmatic workflows.
## When to Apply
Reference this skill when:
- Setting up Prisma Postgres from Prisma Console
- Provisioning instant temporary databases with `create-db`
- Linking an existing local project with `prisma postgres link`
- Managing Prisma Postgres resources via Management API
- Using `@prisma/management-api-sdk` in TypeScript/JavaScript
- Handling claim URLs, connection strings, regions, and auth flows
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | CLI Provisioning | CRITICAL | `create-db-cli` |
| 2 | Management API | CRITICAL | `management-api` |
| 3 | Management API SDK | HIGH | `management-api-sdk` |
| 4 | Console and Connections | HIGH | `console-and-connections` |
## Quick Reference
- `create-db-cli` - instant databases and current CLI flags (`--ttl`, `--copy`, `--quiet`, `--open`)
- `management-api` - service token and OAuth API workflows
- `management-api-sdk` - typed SDK usage with token storage
- `console-and-connections` - Console operations, `prisma postgres link`, direct TCP connections, and serverless-driver choices
## Core Workflows
### 1. Console-first workflow
Use Prisma Console for manual setup and operations:
- Open `https://console.prisma.io`
- Create/select workspace and project
- Use Studio in the project sidebar to view/edit data
- Retrieve direct connection details from the project UI
### 2. Quick provisioning with create-db
Use `create-db` when you need a database immediately:
```bash
npx create-db@latest
```
Aliases:
```bash
npx create-pg@latest
npx create-postgres@latest
```
For app integrations, you can also use the programmatic API (`create()` / `regions()`) from the `create-db` npm package.
Temporary databases auto-delete after ~24 hours unless claimed.
### 2b. Persistent databases with the Platform CLI
For databases that belong to a Project (not throwaway `create-db` databases), use `@prisma/cli`:
```bash
npx -y @prisma/cli@latest database create --help
npx -y @prisma/cli@latest database list --json
npx -y @prisma/cli@latest database connection create db_123
npx -y @prisma/cli@latest database usage db_123
npx -y @prisma/cli@latest database backup list db_123
```
`database create` and `database connection create` print a one-time connection URL; store it immediately. Destructive commands (`remove`, `restore`) require exact `--confirm <id>`.
For automation, prefer `--json --no-interactive`, resolve ids before mutations, and verify the installed command's help because this CLI is beta.
### 3. Link an existing local project
Use `prisma postgres link` when the database already exists and you want to wire a local project to it:
```bash
prisma postgres link
```
For CI or other non-interactive environments:
```bash
prisma postgres link --api-key "<your-api-key>" --database "db_..."
```
This flow updates your local `.env` with `DATABASE_URL`, then you can run `prisma generate` and `prisma migrate dev`.
### 4. Programmatic provisioning with Management API
Use API endpoints on:
```text
https://api.prisma.io/v1
```
Explore the schema and endpoints using:
- OpenAPI docs: `https://api.prisma.io/v1/doc`
- Swagger Editor: `https://api.prisma.io/v1/swagger-editor`
Auth options:
- Service token (workspace server-to-server)
- OAuth 2.0 (act on behalf of users)
### 5. Type-safe integration with Management API SDK
Install and use:
```bash
npm install @prisma/management-api-sdk
```
Use `createManagementApiClient` for existing tokens, or `createManagementApiSdk` for OAuth + token refresh.
The SDK exposes typed workspace service-token list, create, and revoke routes. A newly created token value is returned exactly once. Let the installed SDK types or OpenAPI document settle exact beta endpoint shapes.
## Rule Files
Detailed guidance lives in:
```
references/console-and-connections.md
references/create-db-cli.md
references/management-api.md
references/management-api-sdk.md
```
## How to Use
Start with `references/create-db-cli.md` for fast setup, then switch to `references/management-api.md` or `references/management-api-sdk.md` when you need programmatic provisioning.

View File

@@ -0,0 +1,69 @@
# console-and-connections
Use Prisma Console workflows for project visibility, data inspection, and connection setup.
## Priority
HIGH
## Why It Matters
Many Prisma Postgres tasks are quickest in the Console: viewing Studio data, checking metrics, and retrieving connection details. This avoids unnecessary API or CLI work for simple operational tasks.
## Console workflow
1. Open `https://console.prisma.io`.
2. Select workspace and project.
3. Use dashboard metrics for usage and billing visibility.
4. Open the **Studio** tab in the sidebar to inspect and edit data.
## Local Studio
You can also inspect data locally:
```bash
npx prisma studio
```
## Linking an existing project
If the Prisma Postgres database already exists, link the local project instead of provisioning a new one:
```bash
prisma postgres link
```
For CI or non-interactive usage:
```bash
prisma postgres link --api-key "<your-api-key>" --database "db_..."
```
This command updates or creates `.env` with `DATABASE_URL`. If the project is already linked, use `--force` to re-link. After linking, run `prisma generate`, then `prisma migrate dev` if you need to apply the schema.
## Connection setup
For direct PostgreSQL tools and drivers:
- Generate/copy direct connection credentials from the project connection UI.
- Use the resulting PostgreSQL URL as `DATABASE_URL` for `pg` and `@prisma/adapter-pg`.
- For Prisma Postgres direct TCP, include `sslmode=require`.
Typical direct TCP format:
```env
DATABASE_URL="postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require"
```
Management API connection responses expose both `endpoints.direct` (`db.prisma.io:5432`) and `endpoints.pooled` (`pooled.db.prisma.io:5432`); prefer those fields over the deprecated flat `connectionString`. Connection secrets are shown once at creation (one-time view); store them immediately.
## Adapter choices
- Standard Node.js apps: prefer `@prisma/adapter-pg` with the direct TCP URL above.
- Edge/serverless runtimes: use `@prisma/adapter-ppg` with `@prisma/ppg` only when you specifically need the Prisma Postgres serverless driver.
## References
- [Prisma Postgres overview](https://www.prisma.io/docs/postgres/introduction/overview)
- [Viewing data](https://www.prisma.io/docs/postgres/integrations/viewing-data)
- [Direct connections](https://www.prisma.io/docs/postgres/database/direct-connections)

View File

@@ -0,0 +1,136 @@
# create-db-cli
Use `create-db` for instant Prisma Postgres provisioning from the terminal.
## Priority
CRITICAL
## Why It Matters
`create-db` is the fastest way to get a working Prisma Postgres instance for development, demos, and CI previews. It can also emit machine-readable output and write env variables directly.
## Commands
```bash
npx create-db@latest
npx create-db@latest create [options]
npx create-db@latest regions
```
Aliases:
```bash
npx create-pg@latest
npx create-postgres@latest
```
## Command discovery (`--help`)
Always use `--help` first when integrating CLI commands:
```bash
npx create-db@latest --help
npx create-db@latest create --help
npx create-db@latest regions --help
```
Top-level commands currently exposed:
- `create` (default) to provision a database
- `regions` to list available regions
## `create` options
| Flag | Shorthand | Description |
|---|---|---|
| `--region [string]` | `-r` | Region choice: `ap-southeast-1`, `ap-northeast-1`, `eu-central-1`, `eu-west-3`, `us-east-1`, `us-west-1` |
| `--interactive [boolean]` | `-i` | Open region selector |
| `--json [boolean]` | `-j` | Output machine-readable JSON |
| `--env [string]` | `-e` | Write `DATABASE_URL` and `CLAIM_URL` into a target `.env` |
| `--ttl [string]` | `-t` | Auto-delete after a TTL like `30m` or `1h-24h` |
| `--copy [boolean]` | `-c` | Copy the connection string to the clipboard |
| `--quiet [boolean]` | `-q` | Only print the connection string |
| `--open [boolean]` | `-o` | Open the claim URL in your browser |
## Lifecycle and claim flow
- Databases are temporary by default.
- Unclaimed databases are auto-deleted after ~24 hours.
- Claim the database using the URL shown in command output to keep it permanently.
## Programmatic usage (library API)
You can also use `create-db` programmatically in Node.js/Bun instead of shelling out to the CLI.
Install:
```bash
npm install create-db
# or
bun add create-db
```
Create a database:
```ts
import { create, isDatabaseSuccess, isDatabaseError } from "create-db";
const result = await create({
region: "us-east-1",
userAgent: "my-app/1.0.0",
});
if (isDatabaseSuccess(result)) {
console.log(result.connectionString);
console.log(result.claimUrl);
console.log(result.deletionDate);
}
if (isDatabaseError(result)) {
console.error(result.error, result.message);
}
```
List regions programmatically:
```ts
import { regions } from "create-db";
const available = await regions();
console.log(available);
```
Programmatic `create()` defaults to `us-east-1` if no region is passed.
## Common patterns
```bash
# quick database
npx create-db@latest
# region-specific database
npx create-db@latest --region eu-central-1
# interactive region selection
npx create-db@latest --interactive
# write env vars for app bootstrap
npx create-db@latest --env .env
# auto-delete sooner
npx create-db@latest --ttl 2h
# copy connection string to clipboard
npx create-db@latest --copy
# print only the connection string
npx create-db@latest --quiet
# CI-friendly output
npx create-db@latest --json
```
## References
- [npx create-db docs](https://www.prisma.io/docs/postgres/introduction/npx-create-db)

View File

@@ -0,0 +1,70 @@
# management-api-sdk
Use `@prisma/management-api-sdk` for typed API integration with optional OAuth and token refresh.
The Platform API evolves independently from Prisma ORM. Inspect the installed package's generated `api.d.ts` for exact paths and request/response shapes.
## Priority
HIGH
## Why It Matters
The SDK provides typed endpoint methods and removes boilerplate around auth and refresh handling, which reduces errors in production provisioning flows.
## Install
```bash
npm install @prisma/management-api-sdk
```
## Simple client (existing token)
```typescript
import { createManagementApiClient } from '@prisma/management-api-sdk'
const client = createManagementApiClient({ token: process.env.PRISMA_SERVICE_TOKEN! })
const { data: workspaces } = await client.GET('/v1/workspaces')
```
Check the generated client result before using `data`; typed clients surface HTTP failures separately. Never log a full response from connection/key creation because it may contain one-time credentials.
## Workspace service tokens
The typed client exposes routes to list, create, and revoke workspace service tokens:
- `GET /v1/workspaces/{workspaceId}/service-tokens`
- `POST /v1/workspaces/{workspaceId}/service-tokens`
- `DELETE /v1/workspaces/{workspaceId}/service-tokens/{serviceTokenId}`
Creation accepts a display `name`. The response's `data.value` is the complete token and is returned exactly once; transfer it directly to the intended secret store without logging the response. Later list calls return metadata and `valueHint`, not the token value. Treat revocation as destructive and resolve both ids explicitly.
## Full SDK (OAuth + refresh)
```typescript
import { createManagementApiSdk, type TokenStorage } from '@prisma/management-api-sdk'
const tokenStorage: TokenStorage = {
async getTokens() { return null },
async setTokens(tokens) {},
async clearTokens() {},
}
const api = createManagementApiSdk({
clientId: process.env.PRISMA_CLIENT_ID!,
redirectUri: 'https://your-app.com/auth/callback',
tokenStorage,
})
```
## OAuth SDK flow
1. Call `getLoginUrl()` and persist `state` + `verifier`.
2. Redirect user to login URL.
3. Handle callback with `handleCallback()`.
4. Use `api.client` for typed endpoint calls.
5. Call `logout()` when needed.
## References
- [Management API SDK docs](https://www.prisma.io/docs/postgres/introduction/management-api-sdk)

View File

@@ -0,0 +1,79 @@
# management-api
Use Prisma Management API for programmatic provisioning and workspace/project/database management.
## Priority
CRITICAL
## Why It Matters
When you need backend automation, multi-tenant onboarding flows, or controlled resource provisioning, the Management API is the source of truth and is more reliable than interactive workflows.
## Base URL
```text
https://api.prisma.io/v1
```
## API exploration
- OpenAPI docs: `https://api.prisma.io/v1/doc`
- Swagger Editor: `https://api.prisma.io/v1/swagger-editor`
## Authentication methods
- Service token: best for server-to-server operations in your own workspace
- OAuth 2.0: best for acting on behalf of users across workspaces
## Service token flow
1. Create token in Prisma Console workspace settings.
2. Send token as Bearer auth:
```text
Authorization: Bearer $TOKEN
```
## OAuth flow summary
1. Redirect user to `https://auth.prisma.io/authorize` with `client_id`, `redirect_uri`, `response_type=code`, and scopes.
2. Receive `code` on callback.
3. Exchange code at `https://auth.prisma.io/token`.
4. Use returned access token in Management API requests.
## Resource model
Workspace -> Project -> Branch -> Database. Branches are a first-class resource: databases attach to a Branch, and branch-scoped env/databases are how preview isolation works.
## Current resource inventory
The 1.55 OpenAPI surface includes:
- workspaces, subscriptions, workspace integrations, workspace service tokens, and current-user metadata
- projects, transfers, project databases, and project/branch environment variables
- branches under a project plus branch get/update/delete operations
- databases, usage, backups, restore, connections, and connection rotation
- apps, deployments, promotion/rollback, runtime logs, domains, and build logs
- buckets and bucket keys
- source repositories, SCM installations/install intents, and repositories
- integrations and regions
App/deployment, branch mutation, SCM, and bucket routes include experimental surfaces. Read the installed SDK types or live OpenAPI before building durable automation around them.
Connection create/rotate responses reveal credentials once. Later reads redact or omit the secret, so store the URL immediately. Use the structured direct/pooled endpoint returned by the concrete operation; do not assume a historical flat response shape.
Workspace service-token creation also returns the complete token value exactly once. List calls expose only metadata and a `valueHint`; delete revokes the token. Keep workspace and token ids opaque, and never log a create response.
Database create supports explicit project, region, branch, and source context. A source may be empty, a backup, or another database. Backup records are incremental; rely on current fields and documented units rather than old full-backup examples.
## Notes
- Management API mutation responses may include direct connection credentials; treat the entire response as secret until redacted.
- Prefer an API-provided connection string over manually assembling one from fields.
## References
- [Management API docs](https://www.prisma.io/docs/postgres/introduction/management-api)
- [OpenAPI docs](https://api.prisma.io/v1/doc)
- [Swagger Editor](https://api.prisma.io/v1/swagger-editor)

View File

@@ -0,0 +1,96 @@
# React Hook Form
**Version 2.1.0**
Community
July 2026
> **Note:** This document targets React Hook Form codebases.
> It is mainly for agents and LLMs to follow when maintaining, generating, or refactoring forms.
> Humans may also find it useful, but guidance here is optimized for automation and consistency
> by AI-assisted workflows.
---
## Abstract
Focused guide to the React Hook Form decisions a capable model gets wrong. Contains 35 rules across 7 categories, verified against react-hook-form 7.82.0 by diffing the shipped type definitions and type-checking every code block under tsc --strict against the real package types. Covers where a subscription must live to isolate re-renders, the third useForm generic that transforming resolvers require, the options that silently drop data from the submitted payload (register disabled, shouldUnregister, useFieldArray disabled), the NaN that valueAsNumber produces for an empty input, server error handling via setError('root.*'), resetDefaultValues() for rebasing the dirty baseline after a save, and the Watch / FormStateSubscribe / FieldArray render-prop components. Rules that merely restate what the library already does correctly have been removed.
---
## Table of Contents
1. [Form Configuration](references/_sections.md#1-form-configuration) — **CRITICAL**
- 1.1 [Always Provide defaultValues for Form Initialization](references/formcfg-default-values.md) — CRITICAL (prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore)
- 1.2 [Depend on formState Slices, Not on formState Itself](references/formcfg-useeffect-dependency.md) — HIGH (prevents effects that re-run on every keystroke)
- 1.3 [Justify Any mode Other Than the Default onSubmit](references/formcfg-validation-mode.md) — CRITICAL (prevents a full validation pass and re-render on every keystroke)
- 1.4 [Keep Default reValidateMode Unless Validation Is Expensive](references/formcfg-revalidate-mode.md) — MEDIUM (maintains immediate corrective feedback after first submit)
- 1.5 [Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload](references/formcfg-should-unregister.md) — HIGH (prevents silently dropping values the user already entered)
- 1.6 [Pass the Third useForm Generic When the Resolver Transforms Values](references/formcfg-transformed-values-generic.md) — CRITICAL (makes handleSubmit receive the schema's output type instead of its input type)
- 1.7 [Use Async defaultValues for Server Data](references/formcfg-async-default-values.md) — CRITICAL (eliminates manual useEffect reset patterns)
- 1.8 [Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option](references/formcfg-disabled-prop.md) — MEDIUM (prevents fields silently missing from submission and skipped validation)
- 1.9 [Use the values Prop to Keep a Form in Sync with Server Data](references/formcfg-values-prop.md) — HIGH (replaces a useEffect+reset that overwrites edits whenever the query refetches)
2. [Field Subscription](references/_sections.md#2-field-subscription) — **CRITICAL**
- 2.1 [Avoid Calling watch() in Render for One-Time Reads](references/sub-avoid-watch-in-render.md) — HIGH (prevents unnecessary subscriptions and re-renders)
- 2.2 [React.memo Cannot Stop Context-Driven Re-renders Under FormProvider](references/sub-memo-cannot-beat-context.md) — MEDIUM (replaces a memo pass that has no effect with isolation that does)
- 2.3 [Use subscribe() to React to Form Changes Outside the React Lifecycle](references/sub-subscribe-outside-react.md) — HIGH (eliminates re-renders for non-UI consumers like analytics, autosave, telemetry)
- 2.4 [Use the Render-Prop Components to Isolate Re-renders Without a Child Component](references/sub-render-prop-components.md) — HIGH (confines a subscription to one subtree without authoring a wrapper component)
- 2.5 [Use useFormContext Sparingly for Deep Nesting](references/sub-useformcontext-sparingly.md) — MEDIUM (reduces prop drilling but increases implicit dependencies)
- 2.6 [Use useWatch Instead of watch for Isolated Re-renders](references/sub-usewatch-over-watch.md) — CRITICAL (confines value-change re-renders to the subscribing component)
- 2.7 [Watch Specific Fields Instead of Entire Form](references/sub-watch-specific-fields.md) — CRITICAL (reduces re-renders from N fields to 1 field change)
3. [Controlled Components](references/_sections.md#3-controlled-components) — **HIGH**
- 3.1 [Isolate Controlled Inputs in Dedicated Child Components](references/ctrl-usecontroller-isolation.md) — HIGH (re-renders only the changed field instead of the whole form)
- 3.2 [Wire Controller Field Props Correctly for UI Libraries](references/ctrl-controller-field-props.md) — HIGH (prevents a control that renders correctly but never writes back to the form)
4. [Validation Patterns](references/_sections.md#4-validation-patterns) — **HIGH**
- 4.1 [Build the Validation Schema Once, Outside the Render Path](references/valid-resolver-caching.md) — HIGH (stops rebuilding the whole schema object on every keystroke)
- 4.2 [Handle the NaN valueAsNumber Produces for an Empty Input](references/valid-valueasnumber-empty-nan.md) — HIGH (prevents an optional number field that can never be left blank)
- 4.3 [Surface Server Errors via setError('root.serverError', ...)](references/valid-server-errors.md) — HIGH (prevents lost server-side validation errors and unrecoverable form state)
- 4.4 [Use delayError to Debounce Rapid Error Display](references/valid-delay-error.md) — MEDIUM (reduces UI flicker during fast typing validation)
5. [State Management](references/_sections.md#5-state-management) — **MEDIUM-HIGH**
- 5.1 [Avoid isValid with onSubmit Mode for Button State](references/formstate-avoid-isvalid-with-onsubmit.md) — MEDIUM (prevents whole-form validation on every change under a deferred-validation mode)
- 5.2 [Read Every formState Property You Depend On During Render](references/formstate-destructure-formstate.md) — MEDIUM (prevents a component that never re-renders when the state it shows changes)
- 5.3 [Rebase Defaults with resetDefaultValues After a Successful Save](references/formstate-reset-default-values.md) — HIGH (clears isDirty without discarding edits made during the in-flight request)
- 5.4 [Use handleSubmit's Second Argument to Handle a Rejected Submit](references/formstate-handlesubmit-oninvalid.md) — MEDIUM (gives a failed submit somewhere to go instead of silently doing nothing)
- 5.5 [Use useFormState for Isolated State Subscriptions](references/formstate-useformstate-isolation.md) — MEDIUM (prevents parent re-renders from state access in children)
- 5.6 [Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful](references/formstate-async-submit-lifecycle.md) — HIGH (prevents stuck isSubmitting state and missing post-success reset)
6. [Field Arrays](references/_sections.md#6-field-arrays) — **MEDIUM-HIGH**
- 6.1 [Separate Sequential Field Array Operations](references/array-separate-crud-operations.md) — MEDIUM-HIGH (prevents state corruption from batched mutations)
- 6.2 [Use field.id as Key in useFieldArray Maps](references/array-use-field-id-as-key.md) — MEDIUM-HIGH (prevents state corruption and unnecessary re-renders)
- 6.3 [Use Single useFieldArray Instance Per Field Name](references/array-unique-fieldarray-per-name.md) — MEDIUM-HIGH (prevents state conflicts from duplicate subscriptions)
- 6.4 [useFieldArray's disabled Option Makes Every Mutation a Silent No-op](references/array-disabled-silently-noops.md) — MEDIUM-HIGH (prevents append/remove calls that vanish with no error or warning)
7. [Integration Patterns](references/_sections.md#7-integration-patterns) — **MEDIUM**
- 7.1 [Transform Values at Controller Level for Type Coercion](references/integ-value-transform.md) — MEDIUM (stops string input values reaching a number- or date-typed schema)
- 7.2 [Verify shadcn Form Component Import Source](references/integ-shadcn-form-import.md) — MEDIUM (prevents silent component mismatch bugs)
- 7.3 [Wire shadcn Select with onValueChange Instead of Spread](references/integ-shadcn-select-wiring.md) — MEDIUM (prevents a Radix Select that renders but never writes to the form)
---
## References
1. [https://react-hook-form.com/docs](https://react-hook-form.com/docs)
2. [https://react-hook-form.com/advanced-usage](https://react-hook-form.com/advanced-usage)
3. [https://react-hook-form.com/docs/useform](https://react-hook-form.com/docs/useform)
4. [https://react-hook-form.com/docs/useform/subscribe](https://react-hook-form.com/docs/useform/subscribe)
5. [https://react-hook-form.com/docs/useform/seterror](https://react-hook-form.com/docs/useform/seterror)
6. [https://react-hook-form.com/docs/useform/setvalue](https://react-hook-form.com/docs/useform/setvalue)
7. [https://react-hook-form.com/docs/useform/resetdefaultvalues](https://react-hook-form.com/docs/useform/resetdefaultvalues)
8. [https://react-hook-form.com/docs/useform/formstate](https://react-hook-form.com/docs/useform/formstate)
9. [https://react-hook-form.com/docs/usewatch](https://react-hook-form.com/docs/usewatch)
10. [https://react-hook-form.com/docs/usecontroller](https://react-hook-form.com/docs/usecontroller)
11. [https://react-hook-form.com/docs/usefieldarray](https://react-hook-form.com/docs/usefieldarray)
12. [https://react-hook-form.com/docs/useformstate](https://react-hook-form.com/docs/useformstate)
13. [https://github.com/react-hook-form/react-hook-form/releases](https://github.com/react-hook-form/react-hook-form/releases)
14. [https://github.com/react-hook-form/resolvers](https://github.com/react-hook-form/resolvers)
15. [https://ui.shadcn.com/docs/components/form](https://ui.shadcn.com/docs/components/form)
---
## Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|------|-------------|
| [references/_sections.md](references/_sections.md) | Category definitions and impact ordering |
| [assets/templates/_template.md](assets/templates/_template.md) | Template for creating new rules |
| [SKILL.md](SKILL.md) | Quick reference entry point |
| [metadata.json](metadata.json) | Version and reference URLs |

View File

@@ -0,0 +1,126 @@
# React Hook Form Best Practices Skill
Corrective guidance for React Hook Form — the decisions that go wrong by default.
## Overview
This skill provides 35 rules across 7 categories. Every rule names a decision that a capable model gets wrong by default; rules that merely restated correct library behaviour were removed in 2.0.0. Verified against **react-hook-form 7.82.0** (July 2026), with every code block type-checked under `tsc --strict` against the real package types.
### Directory Structure
```
react-hook-form/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide (TOC over references/)
├── metadata.json # Version, org, references
├── README.md # This file
├── assets/
│ └── templates/
│ └── _template.md # Rule template
└── references/
├── _sections.md # Category definitions and impact ordering
├── formcfg-*.md # Form configuration rules (9)
├── sub-*.md # Field subscription rules (7)
├── ctrl-*.md # Controlled component rules (2)
├── valid-*.md # Validation pattern rules (4)
├── formstate-*.md # State management rules (6)
├── array-*.md # Field array rules (4)
└── integ-*.md # Integration pattern rules (3)
```
## Validating
This skill is validated by the repo-level tooling, not by per-skill scripts:
```bash
npm run validate # structural validation of every skill
node /path/to/dev-skill/scripts/validate-skill.js . # discipline-aware validation of this skill
```
## Creating a New Rule
1. Choose the appropriate category prefix:
| Category | Prefix | Impact |
|----------|--------|--------|
| Form Configuration | `formcfg-` | CRITICAL |
| Field Subscription | `sub-` | CRITICAL |
| Controlled Components | `ctrl-` | HIGH |
| Validation Patterns | `valid-` | HIGH |
| State Management | `formstate-` | MEDIUM-HIGH |
| Field Arrays | `array-` | MEDIUM-HIGH |
| Integration Patterns | `integ-` | MEDIUM |
2. Create a new file: `references/{prefix}-{slug}.md`
3. Use the rule template from `assets/templates/_template.md`
4. Add the rule to the SKILL.md quick reference and to the AGENTS.md table of contents (entries are ordered alphabetically by title within each section).
## Rule File Structure
```markdown
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: What the rule prevents or enables
tags: prefix, keyword1, keyword2
---
## Rule Title Here
Brief explanation of WHY this matters (1-3 sentences).
**Incorrect (description of problem):**
\`\`\`typescript
// Bad code with comment on key line
\`\`\`
**Correct (description of solution):**
\`\`\`typescript
// Good code with minimal diff from incorrect
\`\`\`
Reference: [Documentation Link](https://example.com)
```
## File Naming Convention
Rules follow the pattern: `{prefix}-{slug}.md`
- **prefix**: Category identifier from `_sections.md` (lowercase letters only)
- **slug**: Kebab-case description of the rule
Examples:
- `formcfg-validation-mode.md`
- `sub-usewatch-over-watch.md`
- `ctrl-usecontroller-isolation.md`
## Impact Levels
| Level | Description |
|-------|-------------|
| CRITICAL | Cascade effect on entire form performance |
| HIGH | Significant impact on specific operations |
| MEDIUM-HIGH | Notable improvement for common patterns |
| MEDIUM | Measurable improvement in specific scenarios |
| LOW-MEDIUM | Minor optimization for edge cases |
| LOW | Best practice with minimal performance impact |
## Companion Skill
`react-hook-form-audit` is a static-analysis skill that detects violations of these rules in a Next.js App Router codebase and links each finding back to the corresponding file in `references/`.
## Contributing
1. Read existing rules in the same category for style consistency
2. Ensure incorrect/correct examples have minimal diff
3. Typecheck every example against the pinned react-hook-form version before committing
4. Include authoritative reference links
5. Run validation before submitting
## Acknowledgments
Based on official React Hook Form documentation, the library's shipped type definitions, and community best practices.

View File

@@ -0,0 +1,113 @@
---
name: react-hook-form
description: React Hook Form performance optimization for client-side form validation using useForm, useWatch, useController, useFieldArray, the subscribe() API, and the Watch / FormStateSubscribe / FieldArray render-prop components. Covers RHF 7.82 additions including resetDefaultValues() and the disabled field-array option. This skill should be used when building client-side controlled forms with React Hook Form library. This skill does NOT cover React 19 Server Actions, useActionState, or server-side form handling (use react-19 skill for those).
---
# React Hook Form Best Practices by Community
Comprehensive performance optimization guide for React Hook Form applications. Contains 35 rules across 7 categories, each naming a decision that goes wrong by default. Verified against react-hook-form **7.82.0**.
## When to Apply
Reference these guidelines when:
- Writing new forms with React Hook Form
- Configuring useForm options (mode, defaultValues, validation)
- Subscribing to form values with watch / useWatch / subscribe
- Integrating controlled UI components (MUI, shadcn, Ant Design)
- Managing dynamic field arrays with useFieldArray
- Handling async submit, server errors, and submit lifecycle state
- Reviewing forms for performance issues
## When NOT to Use This Skill
- **React 19 Server Actions / `useActionState`** — use the `react-19` skill instead
- **Deeply nested, fully type-safe forms** — TanStack Form may be a better fit for forms with complex nested schemas; this skill assumes you've already chosen RHF
- **Single-input or trivial forms** — uncontrolled `<form>` + `FormData` is often simpler than pulling in any library
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Form Configuration | CRITICAL | `formcfg-` |
| 2 | Field Subscription | CRITICAL | `sub-` |
| 3 | Controlled Components | HIGH | `ctrl-` |
| 4 | Validation Patterns | HIGH | `valid-` |
| 5 | State Management | MEDIUM-HIGH | `formstate-` |
| 6 | Field Arrays | MEDIUM-HIGH | `array-` |
| 7 | Integration Patterns | MEDIUM | `integ-` |
## Quick Reference
### 1. Form Configuration (CRITICAL)
- `formcfg-default-values` - Always Provide defaultValues for Form Initialization
- `formcfg-useeffect-dependency` - Depend on formState Slices, Not on formState Itself
- `formcfg-validation-mode` - Justify Any mode Other Than the Default onSubmit
- `formcfg-revalidate-mode` - Keep Default reValidateMode Unless Validation Is Expensive
- `formcfg-should-unregister` - Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
- `formcfg-transformed-values-generic` - Pass the Third useForm Generic When the Resolver Transforms Values
- `formcfg-async-default-values` - Use Async defaultValues for Server Data
- `formcfg-disabled-prop` - Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
- `formcfg-values-prop` - Use the values Prop to Keep a Form in Sync with Server Data
### 2. Field Subscription (CRITICAL)
- `sub-avoid-watch-in-render` - Avoid Calling watch() in Render for One-Time Reads
- `sub-memo-cannot-beat-context` - React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
- `sub-subscribe-outside-react` - Use subscribe() to React to Form Changes Outside the React Lifecycle
- `sub-render-prop-components` - Use the Render-Prop Components to Isolate Re-renders Without a Child Component
- `sub-useformcontext-sparingly` - Use useFormContext Sparingly for Deep Nesting
- `sub-usewatch-over-watch` - Use useWatch Instead of watch for Isolated Re-renders
- `sub-watch-specific-fields` - Watch Specific Fields Instead of Entire Form
### 3. Controlled Components (HIGH)
- `ctrl-usecontroller-isolation` - Isolate Controlled Inputs in Dedicated Child Components
- `ctrl-controller-field-props` - Wire Controller Field Props Correctly for UI Libraries
### 4. Validation Patterns (HIGH)
- `valid-resolver-caching` - Build the Validation Schema Once, Outside the Render Path
- `valid-valueasnumber-empty-nan` - Handle the NaN valueAsNumber Produces for an Empty Input
- `valid-server-errors` - Surface Server Errors via setError('root.serverError', ...)
- `valid-delay-error` - Use delayError to Debounce Rapid Error Display
### 5. State Management (MEDIUM-HIGH)
- `formstate-avoid-isvalid-with-onsubmit` - Avoid isValid with onSubmit Mode for Button State
- `formstate-destructure-formstate` - Read Every formState Property You Depend On During Render
- `formstate-reset-default-values` - Rebase Defaults with resetDefaultValues After a Successful Save
- `formstate-handlesubmit-oninvalid` - Use handleSubmit's Second Argument to Handle a Rejected Submit
- `formstate-useformstate-isolation` - Use useFormState for Isolated State Subscriptions
- `formstate-async-submit-lifecycle` - Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
### 6. Field Arrays (MEDIUM-HIGH)
- `array-separate-crud-operations` - Separate Sequential Field Array Operations
- `array-use-field-id-as-key` - Use field.id as Key in useFieldArray Maps
- `array-unique-fieldarray-per-name` - Use Single useFieldArray Instance Per Field Name
- `array-disabled-silently-noops` - useFieldArray's disabled Option Makes Every Mutation a Silent No-op
### 7. Integration Patterns (MEDIUM)
- `integ-value-transform` - Transform Values at Controller Level for Type Coercion
- `integ-shadcn-form-import` - Verify shadcn Form Component Import Source
- `integ-shadcn-select-wiring` - Wire shadcn Select with onValueChange Instead of Spread
## How to Use
Read individual reference files for detailed explanations and code examples:
- [Section definitions](references/_sections.md) - Category structure and impact levels
- [Rule template](assets/templates/_template.md) - Template for adding new rules
- Reference files: `references/{prefix}-{slug}.md`
## Related Skills
- For schema validation with Zod resolver, see `zod` skill
- For React 19 server actions, see `react-19` skill
- For UI/UX form design, see `frontend-design` skill
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`

View File

@@ -0,0 +1,26 @@
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description of impact (e.g., "20-50% improvement")
tags: prefix, tag1, tag2
---
## Rule Title Here
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```typescript
// Bad code example here
const bad = example()
```
**Correct (description of what's right):**
```typescript
// Good code example here
const good = example()
```
Reference: [Link to documentation or resource](https://example.com)

View File

@@ -0,0 +1,41 @@
# Sections
This file defines all sections, their ordering, impact levels, and descriptions.
The section ID (in parentheses) is the filename prefix used to group rules.
---
## 1. Form Configuration (formcfg)
**Impact:** CRITICAL
**Description:** Initial useForm setup determines validation timing, re-render boundaries, and what ends up in the submitted payload. The wrong mode validates on every keystroke; the wrong disabled or shouldUnregister setting silently drops data.
## 2. Field Subscription (sub)
**Impact:** CRITICAL
**Description:** Where a subscription lives decides how much of the tree re-renders. Reading a value at the form root instead of in the leaf that displays it is the difference between re-rendering the whole form and re-rendering one field.
## 3. Controlled Components (ctrl)
**Impact:** HIGH
**Description:** Controller and useController isolate re-renders only when they sit in a child component. Wiring their field props to a third-party control also has to match that library's prop names, or the input renders but never writes back.
## 4. Validation Patterns (valid)
**Impact:** HIGH
**Description:** Where the schema is constructed, how server-side failures re-enter the form, and how error display is paced. Building a schema inside render pays the construction cost on every keystroke.
## 5. State Management (formstate)
**Impact:** MEDIUM-HIGH
**Description:** formState is a per-property Proxy, so a value you never read during render is a value you never re-render for. Submit lifecycle belongs here too: an async handler that throws without a catch strands isSubmitting forever.
## 6. Field Arrays (array)
**Impact:** MEDIUM-HIGH
**Description:** Dynamic field management requires stable keys and one owner per field name. Some options — notably disabled — fail silently rather than loudly.
## 7. Integration Patterns (integ)
**Impact:** MEDIUM
**Description:** Third-party UI library integration (shadcn/Radix, MUI) requires specific wiring, and native inputs need explicit type coercion before values reach a typed schema.

View File

@@ -0,0 +1,56 @@
---
title: useFieldArray's disabled Option Makes Every Mutation a Silent No-op
impact: MEDIUM-HIGH
impactDescription: prevents append/remove calls that vanish with no error or warning
tags: array, useFieldArray, disabled, read-only, no-op
---
## useFieldArray's disabled Option Makes Every Mutation a Silent No-op
`useFieldArray({ disabled })` (RHF 7.79+) is not a UI hint. When `disabled` is truthy, `append`, `prepend`, `insert`, `remove`, `swap`, `move`, `update`, and `replace` each return immediately — no mutation, no error, no console warning. Reaching for it as "grey the rows out while saving" produces a form where clicking Add does nothing and there is nothing in the console to explain why.
**Incorrect (`disabled` tied to submit state — the append silently disappears):**
```typescript
function TeamMembersFields({ control }: { control: Control<TeamForm> }) {
const { isSubmitting } = useFormState({ control })
const { fields, append, remove } = useFieldArray({
control,
name: 'members',
disabled: isSubmitting, // Also kills append/remove, not just the visuals
})
return (
<>
{fields.map((field, index) => (
<MemberRow key={field.id} index={index} onRemove={() => remove(index)} />
))}
<button type="button" onClick={() => append({ email: '' })}>Add member</button>
</>
)
}
```
**Correct (disable the controls; reserve the option for genuinely read-only arrays):**
```typescript
function TeamMembersFields({ control }: { control: Control<TeamForm> }) {
const { isSubmitting } = useFormState({ control })
const { fields, append, remove } = useFieldArray({ control, name: 'members' })
return (
<>
{fields.map((field, index) => (
<MemberRow key={field.id} index={index} onRemove={() => remove(index)} disabled={isSubmitting} />
))}
<button type="button" onClick={() => append({ email: '' })} disabled={isSubmitting}>
Add member
</button>
</>
)
}
```
Use `disabled: true` when the array is structurally immutable for this user — a locked invoice, a plan the current role may not edit — where a mutation slipping through would be a bug. In that case `fields[index].disabled` (7.80+) carries the flag down to each row so the inputs can render disabled from the same source of truth.
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)

View File

@@ -0,0 +1,76 @@
---
title: Separate Sequential Field Array Operations
impact: MEDIUM-HIGH
impactDescription: prevents state corruption from batched mutations
tags: array, useFieldArray, append, remove, sequential
---
## Separate Sequential Field Array Operations
Chaining `append()` and `remove()` in the same handler can cause state corruption. Defer removals to a useEffect or separate user action to allow React to process renders between operations.
**Incorrect (stacked operations cause state issues):**
```typescript
function ReplaceItemForm() {
const { control } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const replaceItem = (indexToReplace: number, newItem: Item) => {
remove(indexToReplace) // Remove old item
append(newItem) // Immediately add new - state may be stale
}
return (
<div>
{fields.map((field, index) => (
<ItemRow
key={field.id}
index={index}
onReplace={(newItem) => replaceItem(index, newItem)}
/>
))}
</div>
)
}
```
**Correct (use update for replacements, or defer operations):**
```typescript
function ReplaceItemForm() {
const { control } = useForm()
const { fields, update } = useFieldArray({ control, name: 'items' })
const replaceItem = (indexToReplace: number, newItem: Item) => {
update(indexToReplace, newItem) // Single atomic operation
}
return (
<div>
{fields.map((field, index) => (
<ItemRow
key={field.id}
index={index}
onReplace={(newItem) => replaceItem(index, newItem)}
/>
))}
</div>
)
}
```
**Alternative (defer removal with useEffect):**
```typescript
const [pendingRemoval, setPendingRemoval] = useState<number | null>(null)
useEffect(() => {
if (pendingRemoval !== null) {
remove(pendingRemoval)
setPendingRemoval(null)
}
}, [pendingRemoval, remove])
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)

View File

@@ -0,0 +1,58 @@
---
title: Use Single useFieldArray Instance Per Field Name
impact: MEDIUM-HIGH
impactDescription: prevents state conflicts from duplicate subscriptions
tags: array, useFieldArray, instance, state-management
---
## Use Single useFieldArray Instance Per Field Name
Each field name should have only one useFieldArray instance. Multiple instances managing the same field name cause state conflicts and unpredictable behavior.
**Incorrect (multiple instances for same field):**
```typescript
function OrderForm() {
const { control } = useForm()
return (
<div>
<ItemsList control={control} />
<ItemsSummary control={control} />
</div>
)
}
function ItemsList({ control }: { control: Control }) {
const { fields, append } = useFieldArray({ control, name: 'items' }) // Instance 1
return <div>{/* render items */}</div>
}
function ItemsSummary({ control }: { control: Control }) {
const { fields } = useFieldArray({ control, name: 'items' }) // Instance 2 - conflicts!
return <div>Total items: {fields.length}</div>
}
```
**Correct (single instance, pass fields down or use useWatch):**
```typescript
function OrderForm() {
const { control } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
return (
<div>
<ItemsList fields={fields} append={append} remove={remove} />
<ItemsSummary control={control} /> {/* Uses useWatch, not useFieldArray */}
</div>
)
}
function ItemsSummary({ control }: { control: Control }) {
const items = useWatch({ control, name: 'items' }) // Read-only subscription
return <div>Total items: {items?.length ?? 0}</div>
}
```
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)

View File

@@ -0,0 +1,56 @@
---
title: Use field.id as Key in useFieldArray Maps
impact: MEDIUM-HIGH
impactDescription: prevents state corruption and unnecessary re-renders
tags: array, useFieldArray, key, react-key
---
## Use field.id as Key in useFieldArray Maps
useFieldArray generates a unique `id` for each field. Using array index as key causes React to lose track of component identity when items are reordered, removed, or inserted.
**Incorrect (index as key causes state corruption):**
```typescript
function IngredientsForm() {
const { control, register } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' })
return (
<div>
{fields.map((field, index) => (
<div key={index}> {/* Index key causes re-render issues */}
<input {...register(`ingredients.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>
</div>
)
}
```
**Correct (field.id ensures stable identity):**
```typescript
function IngredientsForm() {
const { control, register } = useForm()
const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' })
return (
<div>
{fields.map((field, index) => (
<div key={field.id}> {/* Stable identity across operations */}
<input {...register(`ingredients.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>Add</button>
</div>
)
}
```
**Forward compatibility:** `field.id` is correct for all of v7. The v8 beta line renames the generated render key to `field.key` and drops the `keyName` option, so `id` becomes an ordinary data property that no longer guarantees uniqueness. Do not pre-emptively switch on v7 — but if you set `keyName` to something custom today, that is the piece with no v8 equivalent.
Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray)

View File

@@ -0,0 +1,56 @@
---
title: Wire Controller Field Props Correctly for UI Libraries
impact: HIGH
impactDescription: prevents a control that renders correctly but never writes back to the form
tags: ctrl, Controller, field-props, ui-libraries
---
## Wire Controller Field Props Correctly for UI Libraries
Different UI libraries expect different prop names. Map Controller's field props correctly: `onChange` sends data back, `onBlur` reports interaction, `value` sets the display, `ref` enables focus on error.
**Incorrect (spreading field on incompatible component):**
```typescript
function FormWithSelect({ control }: { control: Control<ShippingFormValues> }) {
return (
<Controller
name="country"
control={control}
render={({ field }) => (
<Select {...field} /> // Select may not accept all field props directly
)}
/>
)
}
```
**Correct (manually wire required props):**
```typescript
function FormWithSelect({ control }: { control: Control<ShippingFormValues> }) {
return (
<Controller
name="country"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange} // Map to component's change handler
onBlur={field.onBlur}
>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</Select>
)}
/>
)
}
```
**Common mappings by library:**
- MUI Select: `value`, `onChange` (receives event)
- Radix/shadcn Select: `value`, `onValueChange` (receives value directly)
- React Select: `value`, `onChange` (receives option object)
Reference: [useController](https://react-hook-form.com/docs/usecontroller)

View File

@@ -0,0 +1,80 @@
---
title: Isolate Controlled Inputs in Dedicated Child Components
impact: HIGH
impactDescription: re-renders only the changed field instead of the whole form
tags: ctrl, useController, Controller, controlled-components, re-renders
---
## Isolate Controlled Inputs in Dedicated Child Components
`Controller` and `useController` are equivalent — `Controller` is a thin component wrapper around `useController`. Re-render isolation does **not** come from picking one over the other. It comes from putting the subscription in a **child component**, so that when the field value changes, only the child re-renders. Inlining `Controller` (or `useController`) in the parent form makes every parent re-render flow through every controlled input.
**Incorrect (Controllers inlined in parent — every parent re-render re-renders all controlled inputs):**
```typescript
function PaymentForm() {
const { control, handleSubmit } = useForm<PaymentFormData>()
return (
<form onSubmit={handleSubmit(submitPayment)}>
<Controller
name="amount"
control={control}
render={({ field }) => <CurrencyInput {...field} />}
/>
<Controller
name="currency"
control={control}
render={({ field }) => <CurrencySelect {...field} />}
/>
</form>
)
}
```
**Correct (subscription moved into dedicated child components, isolating re-renders to the changed field):**
```typescript
function PaymentForm() {
const { control, handleSubmit } = useForm<PaymentFormData>()
return (
<form onSubmit={handleSubmit(submitPayment)}>
<AmountInput control={control} />
<CurrencySelectField control={control} />
</form>
)
}
function AmountInput({ control }: { control: Control<PaymentFormData> }) {
const { field } = useController({ name: 'amount', control })
return <CurrencyInput {...field} />
}
function CurrencySelectField({ control }: { control: Control<PaymentFormData> }) {
const { field } = useController({ name: 'currency', control })
return <CurrencySelect {...field} />
}
```
**Equivalent with `Controller` (also correct — same isolation):**
```typescript
function AmountField({ control }: { control: Control<PaymentFormData> }) {
return (
<Controller
name="amount"
control={control}
render={({ field }) => <CurrencyInput {...field} />}
/>
)
}
```
**When to prefer one API over the other:**
- `useController` — when you also need `fieldState`/`formState` in the same component, or want to compose with custom logic
- `Controller` — when you want a single JSX-only declaration and don't need to read state in the surrounding component
Both achieve the same re-render isolation when placed in a child component.
Reference: [useController](https://react-hook-form.com/docs/usecontroller) · [Controller](https://react-hook-form.com/docs/usecontroller/controller)

View File

@@ -0,0 +1,69 @@
---
title: Use Async defaultValues for Server Data
impact: CRITICAL
impactDescription: eliminates manual useEffect reset patterns
tags: formcfg, async, default-values, data-fetching
---
## Use Async defaultValues for Server Data
React Hook Form supports async functions for `defaultValues`, eliminating the need for manual useEffect + reset() patterns when loading initial data from an API.
**Incorrect (manual useEffect reset pattern):**
```typescript
function EditUserForm({ userId }: { userId: string }) {
const { register, reset, handleSubmit, formState: { isLoading } } = useForm({
defaultValues: {
email: '',
name: '',
},
})
useEffect(() => {
async function loadUser() {
const user = await fetchUser(userId)
reset(user) // Manual reset required
}
loadUser()
}, [userId, reset])
if (isLoading) return <Spinner />
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('name')} />
</form>
)
}
```
**Correct (async defaultValues handles loading automatically):**
```typescript
function EditUserForm({ userId }: { userId: string }) {
const { register, handleSubmit, formState: { isLoading } } = useForm({
defaultValues: async () => {
const user = await fetchUser(userId)
return {
email: user.email,
name: user.name,
}
},
})
if (isLoading) return <Spinner />
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input {...register('name')} />
</form>
)
}
```
**Note:** defaultValues are cached after initial load. Use `reset()` with new values if you need to refresh data.
Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,58 @@
---
title: Always Provide defaultValues for Form Initialization
impact: CRITICAL
impactDescription: prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore
tags: formcfg, default-values, initialization, useForm
---
## Always Provide defaultValues for Form Initialization
`useForm<T>()` with no `defaultValues` starts every field as `undefined`. Three things break at once: any controlled input flips from uncontrolled to controlled on first keystroke (React logs a warning and can lose the value), `reset()` with no arguments has no baseline to restore to, and `isDirty`/`dirtyFields` compare against nothing so the form reads as dirty the moment anything is touched. Provide the full shape up front, using empty strings rather than `undefined`.
**Incorrect (no defaultValues — reset() has no baseline, inputs start uncontrolled):**
```typescript
function ProfileForm({ user }: { user: User }) {
const { register, reset, handleSubmit } = useForm<ProfileFormValues>()
useEffect(() => {
reset(user)
}, [user, reset])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('firstName')} />
<input {...register('lastName')} />
<button type="button" onClick={() => reset()}>Discard changes</button>
</form>
)
}
```
**Correct (explicit defaults — reset() restores them, isDirty is meaningful):**
```typescript
function ProfileForm({ user }: { user: User }) {
const { register, reset, handleSubmit } = useForm<ProfileFormValues>({
defaultValues: { firstName: '', lastName: '' },
})
useEffect(() => {
reset(user)
}, [user, reset])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('firstName')} />
<input {...register('lastName')} />
<button type="button" onClick={() => reset()}>Discard changes</button>
</form>
)
}
```
When the defaults come from the server, pass them directly rather than defaulting-then-resetting — see `formcfg-async-default-values`. After a successful save, move the baseline with `resetDefaultValues` rather than `reset` (see `formstate-reset-default-values`).
**Note:** Avoid custom objects with prototype methods (Moment, Luxon) as defaultValues — RHF deep-clones them. Use plain objects or primitives.
Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,83 @@
---
title: Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
impact: MEDIUM
impactDescription: prevents fields silently missing from submission and skipped validation
tags: formcfg, register, disabled, validation, footgun
---
## Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option
Passing `disabled: true` to `register` (or to `useController`/`Controller`) tells RHF the field is "not part of submission": `handleSubmit` deletes the field from the values object it hands your handler, and validation for that field is skipped. It is **not** the same as `<input disabled>` for purely visual disabling. If you only want the input greyed out, use the plain HTML attribute.
The value itself is **not** destroyed — `handleSubmit` unsets disabled names from a *clone* of the form values, so `getValues('promoCode')` still returns what the user typed and re-enabling the field brings it back into the payload. The bug this causes is a field that quietly vanishes from your submit handler while the UI still shows a value in it.
**Incorrect (using register's disabled option for visual disabling — promoCode silently disappears from the submitted payload):**
```typescript
function CheckoutForm() {
const [usingGiftCard, setUsingGiftCard] = useState(false)
const { register, handleSubmit } = useForm<CheckoutFormData>({
defaultValues: { promoCode: '', giftCardCode: '' },
})
return (
<form onSubmit={handleSubmit(submitCheckout)}>
<label>
<input type="checkbox" onChange={(e) => setUsingGiftCard(e.target.checked)} />
Use a gift card
</label>
<input
{...register('promoCode', { disabled: usingGiftCard })}
// When usingGiftCard flips true, submitCheckout receives no promoCode key at all
// and its validation is skipped — while the input still shows the typed value.
/>
<input {...register('giftCardCode', { disabled: !usingGiftCard })} />
</form>
)
}
```
**Correct (use HTML disabled for visual-only disable; use register's disabled only when intentionally excluding the field):**
```typescript
function CheckoutForm() {
const [usingGiftCard, setUsingGiftCard] = useState(false)
const { register, handleSubmit, watch } = useForm<CheckoutFormData & { useShippingForBilling: boolean }>({
defaultValues: { promoCode: '', giftCardCode: '', useShippingForBilling: true, billingAddress: '' },
})
const useShippingForBilling = watch('useShippingForBilling')
return (
<form onSubmit={handleSubmit(submitCheckout)}>
<label>
<input type="checkbox" onChange={(e) => setUsingGiftCard(e.target.checked)} />
Use a gift card
</label>
{/* Visual disable only: value stays in form state, validation still runs */}
<input {...register('promoCode')} disabled={usingGiftCard} />
<input {...register('giftCardCode')} disabled={!usingGiftCard} />
{/* Intentional exclusion: when checked, billingAddress is omitted from submission */}
<label>
<input type="checkbox" {...register('useShippingForBilling')} />
Billing same as shipping
</label>
<input
{...register('billingAddress', {
disabled: useShippingForBilling,
required: !useShippingForBilling,
})}
/>
</form>
)
}
```
**Rule of thumb:**
- Want the field greyed out but still submitted/validated → use the HTML `disabled` attribute directly on the input
- Want the field excluded from submission and validation → use `register('name', { disabled: true })`
If a value is disappearing from your submit handler, check for this option before suspecting `getValues()` — the two disagree by design.
Reference: [register - disabled](https://react-hook-form.com/docs/useform/register)

View File

@@ -0,0 +1,58 @@
---
title: Keep Default reValidateMode Unless Validation Is Expensive
impact: MEDIUM
impactDescription: maintains immediate corrective feedback after first submit
tags: formcfg, revalidate-mode, validation, useForm
---
## Keep Default reValidateMode Unless Validation Is Expensive
After the first submit, `reValidateMode` controls when fields re-validate. The default is `onChange`, which gives users immediate positive feedback the moment they fix an error — this is the recommended UX in most cases ("don't eagerly scold, but eagerly reward"). Only switch to `onBlur` or `onSubmit` when validation is genuinely expensive (async checks, large schemas, heavy regex on long inputs).
**Incorrect (switching reValidateMode to onBlur for a cheap synchronous schema):**
```typescript
function CheckoutForm() {
const { register, handleSubmit } = useForm<CheckoutFormData>({
mode: 'onSubmit',
reValidateMode: 'onBlur', // Hurts UX: user fixes a wrong CVV and gets no feedback until blur
resolver: zodResolver(cheapSyncSchema),
})
return (
<form onSubmit={handleSubmit(placeOrder)}>
<input {...register('cardNumber')} />
<input {...register('cvv')} />
</form>
)
}
```
**Correct (default onChange revalidation; switch only when validation is genuinely expensive):**
```typescript
function CheckoutForm() {
const { register, handleSubmit } = useForm<CheckoutFormData>({
mode: 'onSubmit',
// reValidateMode: 'onChange' is the default — leave it for immediate feedback on correction.
// Switch to 'onBlur' only if you have an async check or >16ms-per-keystroke validation cost.
resolver: zodResolver(cheapSyncSchema),
})
return (
<form onSubmit={handleSubmit(placeOrder)}>
<input {...register('cardNumber')} />
<input {...register('cvv')} />
</form>
)
}
```
**When to deviate from the default:**
- Validation involves a network call or expensive computation (>16ms per keystroke)
- The form has dozens of fields and post-submit re-render cost is measurable in profiling
- The error message is purely informational, not correctable in real time
Otherwise keep `onChange` — users who just fixed an error get instant validation success, which is the UX the RHF defaults are tuned for.
Reference: [useForm - reValidateMode](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,56 @@
---
title: Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
impact: HIGH
impactDescription: prevents silently dropping values the user already entered
tags: formcfg, should-unregister, dynamic-forms, conditional-fields
---
## Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload
The default (`shouldUnregister: false`) keeps a field's value in form state after its input unmounts. That is the right default and it is not a memory problem — the retained data is a few strings per field. `shouldUnregister: true` is a **submission-shape** option: it removes unmounted fields from the values object entirely. Reaching for it to "clean up" a multi-step wizard silently deletes everything the user entered on step 1 the moment they advance to step 2.
**Incorrect (wizard loses step 1 the moment step 2 renders):**
```typescript
function OnboardingWizard() {
const [step, setStep] = useState(1)
const { register, handleSubmit } = useForm<OnboardingData>({
shouldUnregister: true, // personalName is dropped as soon as step 1 unmounts
defaultValues: { personalName: '', companyName: '' },
})
return (
<form onSubmit={handleSubmit(completeOnboarding)}>
{step === 1 && <input {...register('personalName')} />}
{step === 2 && <input {...register('companyName')} />}
<button type="button" onClick={() => setStep(2)}>Next</button>
</form>
)
}
```
**Correct (default retention — every step survives to submit):**
```typescript
function OnboardingWizard() {
const [step, setStep] = useState(1)
const { register, handleSubmit } = useForm<OnboardingData>({
defaultValues: { personalName: '', companyName: '' },
})
return (
<form onSubmit={handleSubmit(completeOnboarding)}>
{step === 1 && <input {...register('personalName')} />}
{step === 2 && <input {...register('companyName')} />}
<button type="button" onClick={() => setStep(2)}>Next</button>
</form>
)
}
```
**When `shouldUnregister: true` is the right call:**
- A discriminated payload where the hidden branch's keys must be absent, not empty — e.g. a "Business" account sends `taxId` and a "Personal" one must not send the key at all
- A backend that treats a present-but-empty key differently from an absent one
- Set it per field via `register('taxId', { shouldUnregister: true })` rather than form-wide, so the rest of the form keeps the safe default
Reference: [useForm - shouldUnregister](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,70 @@
---
title: Pass the Third useForm Generic When the Resolver Transforms Values
impact: CRITICAL
impactDescription: makes handleSubmit receive the schema's output type instead of its input type
tags: formcfg, generics, resolver, zod, transform, typescript
---
## Pass the Third useForm Generic When the Resolver Transforms Values
`useForm` takes three generics: `useForm<TFieldValues, TContext, TTransformedValues>`. The first is what lives in the form (what inputs produce, before validation); the third is what `handleSubmit` hands your success callback. They are the same type only when the schema does no transformation.
The moment a schema uses `z.coerce`, `.transform()`, or a `.default()`, input and output diverge — the form holds a string, the schema yields a `Date` or a `number`. Omit the third generic and TypeScript pins the output to the input type, which the resolver then contradicts. With `@hookform/resolvers` v5 the error lands on the `resolver:` property and reads like this:
```text
Type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: Date; … }>' is not assignable to
type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: string; … }>'.
Types of property 'arrivesOn' are incompatible.
Type 'Date' is not assignable to type 'string'.
```
Nothing in that message mentions a missing generic, so the usual reactions are to cast the resolver, widen the schema until the transform is gone, or drop the resolver's type entirely — all of which trade a correct error for silently wrong types. This is the most common typing failure in RHF + Zod and the fix is one type argument.
**Incorrect (one generic — the resolver's output type contradicts the form's, and handleSubmit is typed with the pre-validation input):**
```typescript
const bookingSchema = z.object({
guests: z.coerce.number().int().min(1),
arrivesOn: z.iso.date().transform((value) => new Date(value)),
})
type BookingInput = z.input<typeof bookingSchema>
function BookingForm() {
const { register, handleSubmit } = useForm<BookingInput>({
resolver: zodResolver(bookingSchema),
defaultValues: { guests: '1', arrivesOn: '' },
})
// The resolver above fails to typecheck; values.arrivesOn is string, but is a Date at runtime
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
}
```
**Correct (three generics — the callback is typed with the schema's output):**
```typescript
const bookingSchema = z.object({
guests: z.coerce.number().int().min(1),
arrivesOn: z.iso.date().transform((value) => new Date(value)),
})
type BookingInput = z.input<typeof bookingSchema>
type BookingOutput = z.output<typeof bookingSchema>
function BookingForm() {
const { register, handleSubmit } = useForm<BookingInput, unknown, BookingOutput>({
resolver: zodResolver(bookingSchema),
defaultValues: { guests: '1', arrivesOn: '' },
})
// values.arrivesOn is Date, values.guests is number — matching runtime
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
}
```
The middle generic is the resolver context; pass `unknown` when you don't use one. Derive both types from the schema (`z.input` / `z.output`) rather than hand-writing them, so they can't drift.
If `handleSubmit` is fighting you about a field type, check this generic before reaching for `as`.
Reference: [useForm](https://react-hook-form.com/docs/useform) · [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)

View File

@@ -0,0 +1,62 @@
---
title: Depend on formState Slices, Not on formState Itself
impact: HIGH
impactDescription: prevents effects that re-run on every keystroke
tags: formcfg, useEffect, dependencies, formState
---
## Depend on formState Slices, Not on formState Itself
The `useForm()` return object is **stable**. `useForm` keeps it in a `useRef` and returns the same object on every render, mutating `formState` onto it — so `useEffect(fn, [form])` runs once, and `register`, `reset`, `control`, `setValue` and friends are all safe dependencies. (Widely repeated advice says the form object is a fresh reference each render and loops; that is not true of any v7 release.)
The `formState` **proxy** is the part that changes. It is rebuilt via `useMemo` keyed on the underlying state, so it gets a new identity on every form-state update — every keystroke, in `onChange` mode. Depending on it re-runs the effect that often. Depend on the specific boolean you care about.
**Incorrect (effect re-runs on every form-state update, not just on success):**
```typescript
function ContactForm({ onSaved }: { onSaved: () => void }) {
const { register, handleSubmit, reset, formState } = useForm({
defaultValues: { email: '' },
})
useEffect(() => {
if (formState.isSubmitSuccessful) {
reset()
onSaved()
}
}, [formState, reset, onSaved]) // New proxy identity on every keystroke
return (
<form onSubmit={handleSubmit(saveContact)}>
<input {...register('email')} />
</form>
)
}
```
**Correct (depend on the slice that actually gates the effect):**
```typescript
function ContactForm({ onSaved }: { onSaved: () => void }) {
const { register, handleSubmit, reset, formState: { isSubmitSuccessful } } = useForm({
defaultValues: { email: '' },
})
useEffect(() => {
if (isSubmitSuccessful) {
reset()
onSaved()
}
}, [isSubmitSuccessful, reset, onSaved]) // Only flips once per successful submit
return (
<form onSubmit={handleSubmit(saveContact)}>
<input {...register('email')} />
</form>
)
}
```
Destructuring also matters for a second reason: reading `formState.isSubmitSuccessful` is what registers the Proxy subscription in the first place — see `formstate-destructure-formstate`.
Reference: [useForm](https://react-hook-form.com/docs/useform) · [formState](https://react-hook-form.com/docs/useform/formstate)

View File

@@ -0,0 +1,55 @@
---
title: Justify Any mode Other Than the Default onSubmit
impact: CRITICAL
impactDescription: prevents a full validation pass and re-render on every keystroke
tags: formcfg, validation-mode, re-renders, useForm
---
## Justify Any mode Other Than the Default onSubmit
`mode` decides when RHF validates. The default is `'onSubmit'`, and you should have to argue your way off it: `'onChange'` runs the field's validation — the whole resolver schema, if you use one — and re-renders on every keystroke. It gets reached for reflexively because "validate as they type" sounds like better UX, when in practice it means showing someone an "invalid email" error while they are still on the third character.
**Incorrect (onChange chosen by default — errors fire mid-word, every keystroke re-validates):**
```typescript
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm<RegistrationData>({
mode: 'onChange',
defaultValues: { email: '' },
})
return (
<form onSubmit={handleSubmit(createAccount)}>
<input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' } })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
)
}
```
**Correct (leave the default; escalate only where it earns its keep):**
```typescript
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm<RegistrationData>({
defaultValues: { email: '' },
})
return (
<form onSubmit={handleSubmit(createAccount)}>
<input {...register('email', { pattern: { value: /^\S+@\S+$/, message: 'Enter a valid email' } })} />
{errors.email && <span>{errors.email.message}</span>}
</form>
)
}
```
`reValidateMode` already defaults to `'onChange'`, so a field that has *failed* validation does give immediate feedback as the user corrects it — which is what people usually think they need `mode: 'onChange'` for.
**Modes worth the escalation:**
- `onTouched` — validate after the first blur, then on change. The usual right answer when submit-time errors feel too late.
- `onBlur` — validate on blur only; quieter than `onTouched` while correcting.
- `onChange` — password-strength meters, "username is available" checks, live-computed totals. Add a comment saying which.
- `all` — `onBlur` and `onChange` together; rarely justified.
Reference: [useForm - mode](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,61 @@
---
title: Use the values Prop to Keep a Form in Sync with Server Data
impact: HIGH
impactDescription: replaces a useEffect+reset that overwrites edits whenever the query refetches
tags: formcfg, values, resetOptions, react-query, server-state
---
## Use the values Prop to Keep a Form in Sync with Server Data
When the initial data arrives from a query, the reflex is `useEffect(() => reset(data), [data])`. That works until the query refetches — on window focus, on interval, after an unrelated mutation — and the effect fires again mid-edit, wiping whatever the user had typed.
`useForm({ values })` is the built-in answer. RHF re-syncs the form when the `values` reference changes, and `resetOptions: { keepDirtyValues: true }` tells it to leave fields the user has touched alone while updating the ones they haven't. `defaultValues` still supplies the shape before the first response lands.
**Incorrect (effect-driven reset — a background refetch discards in-progress edits):**
```typescript
function ProfileForm({ userId }: { userId: string }) {
const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile })
const { register, reset, handleSubmit } = useForm<ProfileFormValues>({
defaultValues: { displayName: '', bio: '' },
})
useEffect(() => {
if (profile) reset(profile) // Fires again on every refetch, mid-edit
}, [profile, reset])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
</form>
)
}
```
**Correct (declarative sync that preserves what the user has touched):**
```typescript
function ProfileForm({ userId }: { userId: string }) {
const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile })
const { register, handleSubmit } = useForm<ProfileFormValues>({
defaultValues: { displayName: '', bio: '' },
values: profile,
resetOptions: { keepDirtyValues: true },
})
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
</form>
)
}
```
**Which initialiser to reach for:**
- `defaultValues` — the shape and the baseline; required regardless (see `formcfg-default-values`)
- `values` — the record is fetched and may change while the form is open
- async `defaultValues` — the record is fetched once and will not change under the form (see `formcfg-async-default-values`)
Drop `keepDirtyValues` only when a server change should win over the user's edit — a record another person may be editing concurrently, for instance.
Reference: [useForm - values](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,79 @@
---
title: Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
impact: HIGH
impactDescription: prevents stuck isSubmitting state and missing post-success reset
tags: formstate, isSubmitting, isSubmitSuccessful, async, submit, reset
---
## Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful
`isSubmitting` is the canonical way to disable the submit button while a request is in flight, but it has a well-known footgun: if your submit handler **throws**, `isSubmitting` stays `true` and the form becomes unrecoverable. Always `try/catch` inside the async handler. Pair this with `isSubmitSuccessful` + `useEffect(reset)` to clear the form after a successful submit (resetting inside the handler races with the success state transition).
**Incorrect (throw leaves isSubmitting stuck; manual reset races):**
```typescript
function CreatePostForm() {
const { register, handleSubmit, reset, formState: { isSubmitting } } = useForm<PostFormData>()
const onSubmit = async (data: PostFormData) => {
const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) throw new Error('Save failed') // isSubmitting will stay true forever
reset() // Races with the form's success state
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} />
<textarea {...register('body')} />
<button disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Save'}</button>
</form>
)
}
```
**Correct (try/catch keeps form recoverable; useEffect resets after success):**
```typescript
function CreatePostForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { isSubmitting, isSubmitSuccessful, errors },
} = useForm<PostFormData>()
const onSubmit = async (data: PostFormData) => {
try {
const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
setError('root.serverError', { type: 'server', message: 'Save failed' })
}
} catch {
setError('root.serverError', { type: 'network', message: 'Network error — please retry' })
}
}
// Reset after a successful submit completes — runs once per success transition
useEffect(() => {
if (isSubmitSuccessful) reset()
}, [isSubmitSuccessful, reset])
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root?.serverError && <div role="alert">{errors.root.serverError.message}</div>}
<input {...register('title')} />
<textarea {...register('body')} />
<button disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Save'}</button>
</form>
)
}
```
**Key details:**
- `isSubmitting` resets only when the handler **returns** (resolves). A throw leaves it `true` and the form unrecoverable
- `isSubmitSuccessful` becomes `true` when the handler completes without throwing and without calling `setError`. Use it to gate the post-success reset
- Calling `reset()` inside the submit handler races with React's commit of `isSubmitSuccessful`; the `useEffect` form is the documented pattern
- If you want to preserve specific fields across reset, pass them: `reset(undefined, { keepDirtyValues: true })` or `reset({ defaultValue: lastSaved })`
Reference: [formState](https://react-hook-form.com/docs/useform/formstate) · [reset](https://react-hook-form.com/docs/useform/reset) · [Discussion #10103 — isSubmitting does not recover when submit handler throws](https://github.com/orgs/react-hook-form/discussions/10103)

View File

@@ -0,0 +1,65 @@
---
title: Avoid isValid with onSubmit Mode for Button State
impact: MEDIUM
impactDescription: prevents whole-form validation on every change under a deferred-validation mode
tags: formstate, isValid, onSubmit, validation-mode
---
## Avoid isValid with onSubmit Mode for Button State
Subscribing to `isValid` opts the form into continuous validation. RHF only computes it when something reads it — `_setValid()` is gated on `isValid` being subscribed — but once it is, RHF runs the **whole form's** validation (the entire resolver schema, not just the changed field) on mount and again on every change event. Choosing `mode: 'onSubmit'` to defer validation and then reading `isValid` to grey out the submit button cancels out the deferral you asked for.
(The cost is per change event, not per render: re-rendering the component without touching a field does not re-validate.)
**Incorrect (isValid re-validates the whole form on every change despite onSubmit mode):**
```typescript
function RegistrationForm() {
const { register, handleSubmit, formState: { isValid } } = useForm<RegistrationData>({
defaultValues: { email: '', password: '' }, // mode defaults to 'onSubmit'
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
<button disabled={!isValid}>Register</button> {/* Opts the form into validating on every change */}
</form>
)
}
```
**Correct (use isSubmitting or allow submit attempt):**
```typescript
function RegistrationForm() {
const { register, handleSubmit, formState: { isSubmitting } } = useForm<RegistrationData>({
defaultValues: { email: '', password: '' },
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
<button disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}
```
**Alternative:** if a live-disabled submit button really is the requirement, say so explicitly rather than leaving the two settings in tension — pair `isValid` with a mode that already validates continuously:
```typescript
function RegistrationForm() {
const { register, formState: { isValid } } = useForm<RegistrationData>({
mode: 'onChange', // Deliberate: the button reflects validity as the user types
defaultValues: { email: '', password: '' },
})
return <button disabled={!isValid}>Register</button>
}
```
Reference: [useForm - mode](https://react-hook-form.com/docs/useform)

View File

@@ -0,0 +1,49 @@
---
title: Read Every formState Property You Depend On During Render
impact: MEDIUM
impactDescription: prevents a component that never re-renders when the state it shows changes
tags: formstate, formState, proxy, subscription, conditional
---
## Read Every formState Property You Depend On During Render
`formState` is a Proxy: each property has a getter that marks it subscribed the first time it is read. Subscription is established by the *read*, not by how you write it — `formState.isValid`, `const { isValid } = formState`, and destructuring in the `useForm` call are all equivalent, and all three subscribe to exactly `isValid`. (The common claim that touching the whole object "disables the optimization" is not true; the getters are per-property.)
The real trap is a property that is never read during render. Read it only inside a callback, or only in a branch that doesn't run on the first render, and the getter never fires — so RHF never re-renders the component when that property changes, and the UI silently stops updating.
**Incorrect (isSubmitting is only read inside the handler — the button label never updates):**
```typescript
function SaveButton() {
const { handleSubmit, formState } = useForm<ArticleDraft>({ defaultValues: emptyDraft })
const onClick = handleSubmit(async (values) => {
if (formState.isSubmitting) return // First read happens in a callback, after render
await saveDraft(values)
})
return <button onClick={onClick}>Save</button>
}
```
**Correct (read it in the render body, so the subscription exists):**
```typescript
function SaveButton() {
const { handleSubmit, formState: { isSubmitting } } = useForm<ArticleDraft>({ defaultValues: emptyDraft })
const onClick = handleSubmit(async (values) => {
await saveDraft(values)
})
return (
<button onClick={onClick} disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save'}
</button>
)
}
```
The same applies to a conditional read: `{step === 2 && errors.email && …}` does not subscribe to `errors` until `step` reaches 2. Destructuring at the top of the component is the habit that makes this a non-issue, which is the real reason to do it.
Reference: [formState](https://react-hook-form.com/docs/useform/formstate) · [useFormState](https://react-hook-form.com/docs/useformstate)

View File

@@ -0,0 +1,57 @@
---
title: Use handleSubmit's Second Argument to Handle a Rejected Submit
impact: MEDIUM
impactDescription: gives a failed submit somewhere to go instead of silently doing nothing
tags: formstate, handleSubmit, onInvalid, errors, accessibility
---
## Use handleSubmit's Second Argument to Handle a Rejected Submit
`handleSubmit(onValid, onInvalid)` takes two callbacks. Almost all code passes only the first, so when validation fails the click does nothing observable: no navigation, no request, and — on a long form — an error message somewhere below the fold that the user never scrolls to. They press the button again, harder.
`onInvalid` receives the same `FieldErrors` object as `formState.errors` and is the natural place to move focus to the first bad field, scroll it into view, announce it, or record that submission is failing.
**Incorrect (invalid submit is a no-op from the user's point of view):**
```typescript
function ApplicationForm() {
const { register, handleSubmit } = useForm<ApplicationValues>({
defaultValues: emptyApplication,
})
return (
<form onSubmit={handleSubmit(submitApplication)}>
{/* 40 fields; the invalid one may be far off-screen */}
<button type="submit">Submit application</button>
</form>
)
}
```
**Correct (failed validation moves the user to the problem):**
```typescript
function ApplicationForm() {
const { register, handleSubmit, setFocus } = useForm<ApplicationValues>({
defaultValues: emptyApplication,
})
const onInvalid = (errors: FieldErrors<ApplicationValues>) => {
const firstField = Object.keys(errors)[0] as FieldPath<ApplicationValues> | undefined
if (firstField) setFocus(firstField, { shouldSelect: true })
trackEvent('application_submit_rejected', { fieldCount: Object.keys(errors).length })
}
return (
<form onSubmit={handleSubmit(submitApplication, onInvalid)}>
<button type="submit">Submit application</button>
</form>
)
}
```
Note the two callbacks are typed differently: `onValid` receives the schema's **output** type (see `formcfg-transformed-values-generic`), while `onInvalid` receives errors keyed on the form's input type.
RHF also focuses the first errored field itself when the field was registered with a ref — `onInvalid` is what you need when the control is custom, virtualized, or on another wizard step, where there is no ref to focus.
Reference: [handleSubmit](https://react-hook-form.com/docs/useform/handlesubmit)

View File

@@ -0,0 +1,70 @@
---
title: Rebase Defaults with resetDefaultValues After a Successful Save
impact: HIGH
impactDescription: clears isDirty without discarding edits made during the in-flight request
tags: formstate, resetDefaultValues, isDirty, dirtyFields, save
---
## Rebase Defaults with resetDefaultValues After a Successful Save
For a form that stays mounted after saving (settings pages, inline editors), the goal after a successful `PATCH` is to make `isDirty` false again — the saved values are the new baseline. The reflex is `reset(savedValues)`, but `reset` writes **both** `defaultValues` and the live form values. Any keystroke the user made while the request was in flight is silently thrown away, and controlled inputs re-mount.
`resetDefaultValues(savedValues)` (RHF 7.77+, also exposed on `useFormContext` since 7.82) replaces `defaultValues` and recomputes `dirtyFields`/`isDirty` against the values already in the form, without touching them. Edits made during the save stay put and are correctly reported as dirty.
**Incorrect (reset discards edits made while the save was in flight):**
```typescript
function NotificationSettingsForm({ settings }: { settings: NotificationSettings }) {
const { register, handleSubmit, reset, formState: { isDirty, isSubmitting } } =
useForm({ defaultValues: settings })
const onSubmit = async (values: NotificationSettings) => {
try {
const saved = await updateNotificationSettings(values)
reset(saved) // Overwrites live values — anything typed during the request is lost
} catch {
setError('root.serverError', { message: 'Could not save settings' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('digestEmail')} />
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
</form>
)
}
```
**Correct (rebase the baseline, keep the user's in-flight edits):**
```typescript
function NotificationSettingsForm({ settings }: { settings: NotificationSettings }) {
const { register, handleSubmit, resetDefaultValues, setError, formState: { isDirty, isSubmitting } } =
useForm({ defaultValues: settings })
const onSubmit = async (values: NotificationSettings) => {
try {
const saved = await updateNotificationSettings(values)
resetDefaultValues(saved) // New baseline; live values untouched, isDirty recomputed against them
} catch {
setError('root.serverError', { message: 'Could not save settings' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('digestEmail')} />
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
</form>
)
}
```
**Which to reach for:**
- `resetDefaultValues(saved)` — form stays mounted and the user keeps editing; you only want the dirty baseline moved
- `reset(saved)` — you genuinely want to discard the current values too (form closes, or you are loading a different record)
`resetDefaultValues` accepts `{ keepDirty }` and `{ keepIsValid }` if you need to suppress either recomputation.
Reference: [useForm - resetDefaultValues](https://react-hook-form.com/docs/useform/resetdefaultvalues)

View File

@@ -0,0 +1,71 @@
---
title: Use useFormState for Isolated State Subscriptions
impact: MEDIUM
impactDescription: prevents parent re-renders from state access in children
tags: formstate, useFormState, isolation, re-renders
---
## Use useFormState for Isolated State Subscriptions
useFormState allows subscribing to form state in child components without causing parent re-renders. Each useFormState instance is isolated and doesn't affect other subscribers.
**Incorrect (formState at root re-renders entire form):**
```typescript
function ContactForm() {
const { register, handleSubmit, formState: { errors, isDirty } } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
{errors.email && <span>Email required</span>} {/* Re-renders all on any state change */}
<input {...register('message')} />
<SaveIndicator isDirty={isDirty} /> {/* Prop drilling */}
</form>
)
}
```
**Correct (useFormState isolates subscriptions):**
```typescript
function ContactForm() {
const { register, handleSubmit, control } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<EmailField register={register} control={control} />
<input {...register('message')} />
<SaveIndicator control={control} /> {/* Isolated subscription */}
</form>
)
}
function EmailField({ register, control }: EmailFieldProps) {
const { errors } = useFormState({ control, name: 'email' })
return (
<div>
<input {...register('email', { required: true })} />
{errors.email && <span>Email required</span>}
</div>
)
}
function SaveIndicator({ control }: { control: Control }) {
const { isDirty } = useFormState({ control })
return isDirty ? <span>Unsaved changes</span> : null
}
```
**Scope it further with `name`.** An isolated `useFormState({ control })` still re-renders on *any* field's state change. Pass `name` to narrow it to the fields the component actually displays:
```typescript
function EmailFieldError({ control }: { control: Control<SignupFormValues> }) {
const { errors } = useFormState({ control, name: 'email' })
return errors.email ? <span>{errors.email.message}</span> : null
}
```
Reference: [useFormState](https://react-hook-form.com/docs/useformstate)

View File

@@ -0,0 +1,64 @@
---
title: Verify shadcn Form Component Import Source
impact: MEDIUM
impactDescription: prevents silent component mismatch bugs
tags: integ, shadcn, imports, Form-component
---
## Verify shadcn Form Component Import Source
React Hook Form exports its own `<Form>` component. When using shadcn/ui, ensure you import the shadcn Form wrapper, not RHF's Form. Auto-imports often get this wrong.
**Incorrect (imports RHF Form instead of shadcn):**
```typescript
import { useForm, Form } from 'react-hook-form' // Wrong Form!
import { FormField, FormItem, FormLabel } from '@/components/ui/form'
function LoginForm() {
const form = useForm()
return (
<Form {...form}> {/* RHF Form doesn't work with shadcn FormField */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Input {...field} />
</FormItem>
)}
/>
</Form>
)
}
```
**Correct (separate imports for each library):**
```typescript
import { useForm } from 'react-hook-form'
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'
function LoginForm() {
const form = useForm()
return (
<Form {...form}> {/* shadcn Form wraps FormProvider correctly */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Input {...field} />
</FormItem>
)}
/>
</Form>
)
}
```
Reference: [shadcn Form](https://ui.shadcn.com/docs/components/form)

View File

@@ -0,0 +1,64 @@
---
title: Wire shadcn Select with onValueChange Instead of Spread
impact: MEDIUM
impactDescription: prevents a Radix Select that renders but never writes to the form
tags: integ, shadcn, select, radix
---
## Wire shadcn Select with onValueChange Instead of Spread
shadcn's Select (built on Radix) uses `onValueChange` instead of `onChange`. Spreading field props directly doesn't work. Manually wire the value change handler.
**Incorrect (spread doesn't work with Radix Select):**
```typescript
function CountrySelect({ control }: { control: Control }) {
return (
<FormField
control={control}
name="country"
render={({ field }) => (
<Select {...field}> {/* field.onChange expects event, Radix passes value */}
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</SelectContent>
</Select>
)}
/>
)
}
```
**Correct (wire props individually):**
```typescript
function CountrySelect({ control }: { control: Control }) {
return (
<FormField
control={control}
name="country"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange} // Radix passes value directly
onOpenChange={() => field.onBlur()} // Trigger blur on close
>
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</SelectContent>
</Select>
)}
/>
)
}
```
Reference: [shadcn Select](https://ui.shadcn.com/docs/components/select)

View File

@@ -0,0 +1,59 @@
---
title: Transform Values at Controller Level for Type Coercion
impact: MEDIUM
impactDescription: stops string input values reaching a number- or date-typed schema
tags: integ, transform, value-coercion, Controller
---
## Transform Values at Controller Level for Type Coercion
Native inputs return strings. When your form needs numbers, dates, or other types, transform values in the Controller render function rather than relying solely on `valueAsNumber` or `valueAsDate`.
**Incorrect (valueAsNumber has edge cases):**
```typescript
function QuantityInput() {
const { register } = useForm()
return (
<input
{...register('quantity', { valueAsNumber: true })} // Returns NaN for empty string
type="number"
/>
)
}
```
**Correct (explicit transformation in Controller):**
```typescript
function QuantityInput({ control }: { control: Control }) {
return (
<Controller
name="quantity"
control={control}
render={({ field }) => (
<input
type="number"
value={field.value ?? ''}
onChange={(e) => {
const value = e.target.value
field.onChange(value === '' ? null : parseInt(value, 10))
}}
onBlur={field.onBlur}
/>
)}
/>
)
}
```
**Alternative (Zod transform at schema level):**
```typescript
const schema = z.object({
quantity: z.string().transform((val) => (val === '' ? null : parseInt(val, 10))),
})
```
Reference: [React Hook Form - Advanced Usage](https://react-hook-form.com/advanced-usage)

View File

@@ -0,0 +1,48 @@
---
title: Avoid Calling watch() in Render for One-Time Reads
impact: HIGH
impactDescription: prevents unnecessary subscriptions and re-renders
tags: sub, watch, getValues, render, one-time-read
---
## Avoid Calling watch() in Render for One-Time Reads
If you only need to read a value once (not subscribe to changes), use `getValues()` instead of `watch()`. Calling watch() creates a subscription that triggers re-renders on every change.
**Incorrect (watch creates subscription for one-time read):**
```typescript
function SubmitButton() {
const { watch, handleSubmit, formState: { isValid } } = useForm()
const handleClick = () => {
const email = watch('email') // Creates subscription, but we only need current value
analytics.track('form_submit_attempt', { email })
handleSubmit(onSubmit)()
}
return <button onClick={handleClick} disabled={!isValid}>Submit</button>
}
```
**Correct (getValues for one-time read):**
```typescript
function SubmitButton() {
const { getValues, handleSubmit, formState: { isValid } } = useForm()
const handleClick = () => {
const email = getValues('email') // No subscription, just current value
analytics.track('form_submit_attempt', { email })
handleSubmit(onSubmit)()
}
return <button onClick={handleClick} disabled={!isValid}>Submit</button>
}
```
**When to use each:**
- `watch()`: Need to react to value changes (display, conditional rendering)
- `getValues()`: Need current value at a point in time (event handlers, submit)
Reference: [useForm - getValues](https://react-hook-form.com/docs/useform/getvalues)

View File

@@ -0,0 +1,51 @@
---
title: React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
impact: MEDIUM
impactDescription: replaces a memo pass that has no effect with isolation that does
tags: sub, FormProvider, memo, useFormContext, isolation
---
## React.memo Cannot Stop Context-Driven Re-renders Under FormProvider
The instinct when a `FormProvider` tree re-renders too much is to wrap the heavy children in `React.memo`. It does nothing here. `React.memo` compares props; a component that calls `useFormContext()` is a context consumer, and a context value change re-renders it regardless of whether its props were equal. `FormProvider` already memoizes its own value, but `formState` is one of that memo's dependencies, so the value's identity does change on every form-state update — and every `useFormContext()` consumer under it re-renders.
Wrapping in `memo` therefore buys nothing for the components you were worried about, and costs a comparison on every render for the ones you weren't.
**Incorrect (memo on a context consumer — still re-renders on every form-state update):**
```typescript
const AddressSection = React.memo(function AddressSection() {
const { register, control } = useFormContext<CheckoutForm>()
const { errors } = useFormState<CheckoutForm>({ control }) // Consumes context; memo is bypassed
return (
<fieldset>
<input {...register('street')} />
{errors.street && <span>{errors.street.message}</span>}
</fieldset>
)
})
```
**Correct (subscribe to the narrowest slice, so the re-render is cheap and local):**
```typescript
function AddressSection() {
const { register, control } = useFormContext<CheckoutForm>()
return (
<fieldset>
<input {...register('street')} />
<FormStateSubscribe
control={control}
name="street"
render={({ errors }) => (errors.street ? <span>{errors.street.message}</span> : null)}
/>
</fieldset>
)
}
```
`React.memo` is still worth reaching for on a genuinely expensive child that takes plain props and does **not** read form context — a chart, a map, a large static list rendered as a sibling of the form. The distinction is whether the component consumes context at all.
Reference: [FormProvider](https://react-hook-form.com/docs/formprovider) · [useFormState](https://react-hook-form.com/docs/useformstate)

View File

@@ -0,0 +1,76 @@
---
title: Use the Render-Prop Components to Isolate Re-renders Without a Child Component
impact: HIGH
impactDescription: confines a subscription to one subtree without authoring a wrapper component
tags: sub, Watch, FormStateSubscribe, FieldArray, isolation, render-prop
---
## Use the Render-Prop Components to Isolate Re-renders Without a Child Component
Re-render isolation comes from putting the subscription somewhere other than the form component. The usual advice — extract a child component — works but costs a component and a prop-drilled `control` for every watched value. React Hook Form ships render-prop wrappers that do the same thing inline: `<Watch>` wraps `useWatch`, `<FormStateSubscribe>` wraps `useFormState`, and `<FieldArray>` (7.81+) wraps `useFieldArray`. Each is a one-line component that calls the hook and hands the result to `render`, so the subscription lives in that element and only that subtree re-renders.
**Incorrect (hooks in the form component — every keystroke re-renders the whole form):**
```typescript
function InvoiceForm() {
const { control, register, handleSubmit } = useForm<Invoice>({ defaultValues: emptyInvoice })
const { fields, append } = useFieldArray({ control, name: 'lineItems' })
const [quantity, unitPrice] = useWatch({ control, name: ['quantity', 'unitPrice'] })
const { isDirty, isSubmitting } = useFormState({ control })
return (
<form onSubmit={handleSubmit(saveInvoice)}>
{fields.map((field, index) => (
<input key={field.id} {...register(`lineItems.${index}.description`)} />
))}
<button type="button" onClick={() => append({ description: '' })}>Add line</button>
<p>Total: {quantity * unitPrice}</p>
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
</form>
)
}
```
**Correct (each subscription confined to its own element):**
```typescript
function InvoiceForm() {
const { control, register, handleSubmit } = useForm<Invoice>({ defaultValues: emptyInvoice })
return (
<form onSubmit={handleSubmit(saveInvoice)}>
<FieldArray
control={control}
name="lineItems"
render={({ fields, append }) => (
<>
{fields.map((field, index) => (
<input key={field.id} {...register(`lineItems.${index}.description`)} />
))}
<button type="button" onClick={() => append({ description: '' })}>Add line</button>
</>
)}
/>
<Watch
control={control}
name={['quantity', 'unitPrice']}
render={([quantity, unitPrice]) => <p>Total: {quantity * unitPrice}</p>}
/>
<FormStateSubscribe
control={control}
render={({ isDirty, isSubmitting }) => (
<button type="submit" disabled={!isDirty || isSubmitting}>Save</button>
)}
/>
</form>
)
}
```
**Two traps in the current typings:**
- `<Watch>` accepts both `name` and `names`. `names` is marked `@deprecated` in 7.82 and is renamed away in v8 — write `name`, even though the shipped JSDoc example still shows `names`.
- `FieldArrayProps.render` is typed to return `React.ReactElement`, not `ReactNode[]`. Returning `fields.map(...)` directly fails to typecheck despite appearing that way in the shipped JSDoc — wrap the output in a fragment.
Prefer an extracted child component when the subtree needs its own logic, handlers, or memoization; prefer the render-prop component when it is purely "read this value, render this markup".
Reference: [useWatch](https://react-hook-form.com/docs/usewatch) · [useFieldArray](https://react-hook-form.com/docs/usefieldarray)

View File

@@ -0,0 +1,92 @@
---
title: Use subscribe() to React to Form Changes Outside the React Lifecycle
impact: HIGH
impactDescription: eliminates re-renders for non-UI consumers like analytics, autosave, telemetry
tags: sub, subscribe, side-effects, analytics, autosave, useForm
---
## Use subscribe() to React to Form Changes Outside the React Lifecycle
Introduced in v7.55.0, `useForm().subscribe(...)` registers a callback that fires on form state or value changes **without causing any re-renders**. Use it when the consumer of the change is not a UI element — analytics, autosave to localStorage, debounced telemetry, sending drafts to a server. `useWatch` and `watch` are still right for things that paint to screen; `subscribe` is right for everything else.
**Incorrect (using useWatch to drive a non-UI side-effect — re-renders the form on every keystroke):**
```typescript
function ProfileForm() {
const { register, handleSubmit, control } = useForm<ProfileFormData>()
const values = useWatch({ control }) // Every keystroke re-renders ProfileForm
useEffect(() => {
analytics.track('profile_field_edited', { values }) // Fires on every render
}, [values])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
<input {...register('bio')} />
</form>
)
}
```
**Correct (subscribe() runs the side-effect with zero re-renders):**
```typescript
function ProfileForm() {
const { register, handleSubmit, subscribe } = useForm<ProfileFormData>()
useEffect(() => {
const unsubscribe = subscribe({
formState: { values: true },
callback: ({ values, name }) => {
analytics.track('profile_field_edited', { field: name, values })
},
})
return unsubscribe
}, [subscribe])
return (
<form onSubmit={handleSubmit(saveProfile)}>
<input {...register('displayName')} />
<input {...register('bio')} />
</form>
)
}
```
**Subscribing to specific fields with formState slices (e.g. dirty-aware autosave):**
```typescript
function DraftEditor() {
const { register, subscribe } = useForm<DraftFormData>({
defaultValues: loadDraft(),
})
useEffect(() => {
const unsubscribe = subscribe({
name: ['title', 'body'],
formState: { values: true, isDirty: true },
callback: ({ values, isDirty }) => {
if (isDirty) debouncedSaveDraft(values)
},
})
return unsubscribe
}, [subscribe])
return (
<>
<input {...register('title')} />
<textarea {...register('body')} />
</>
)
}
```
**When to use which:**
- `useWatch` / `Controller` — the value drives a rendered element
- `subscribe` — the value drives a non-UI side-effect (analytics, autosave, localStorage sync, telemetry)
- `watch(callback)` — legacy callback form; prefer `subscribe` in new code (subscribe replaces the watch-callback pattern with explicit formState slicing and no implicit re-renders)
`subscribe` returns an unsubscribe function — always return it from the `useEffect` cleanup to avoid leaks across remounts.
Reference: [subscribe](https://react-hook-form.com/docs/useform/subscribe) · [Release notes v7.55.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.55.0)

View File

@@ -0,0 +1,58 @@
---
title: Use useFormContext Sparingly for Deep Nesting
impact: MEDIUM
impactDescription: reduces prop drilling but increases implicit dependencies
tags: sub, useFormContext, FormProvider, prop-drilling
---
## Use useFormContext Sparingly for Deep Nesting
useFormContext eliminates prop drilling by accessing form methods via context, but creates implicit dependencies that are harder to track. Use it for deeply nested components; prefer explicit props for shallow nesting.
**Incorrect (useFormContext for shallow nesting):**
```typescript
function ContactForm() {
const methods = useForm()
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NameInput /> {/* One level deep, context overhead not needed */}
<EmailInput />
</form>
</FormProvider>
)
}
function NameInput() {
const { register } = useFormContext() // Implicit dependency
return <input {...register('name')} />
}
```
**Correct (explicit props for shallow nesting):**
```typescript
function ContactForm() {
const { register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<NameInput register={register} /> {/* Explicit dependency */}
<EmailInput register={register} />
</form>
)
}
function NameInput({ register }: { register: UseFormRegister<ContactFormData> }) {
return <input {...register('name')} />
}
```
**When to use useFormContext:**
- Components nested 3+ levels deep
- Shared components used across multiple forms
- Complex form sections with many fields
Reference: [useFormContext](https://react-hook-form.com/docs/useformcontext)

View File

@@ -0,0 +1,60 @@
---
title: Use useWatch Instead of watch for Isolated Re-renders
impact: CRITICAL
impactDescription: confines value-change re-renders to the subscribing component
tags: sub, useWatch, watch, re-renders, subscription
---
## Use useWatch Instead of watch for Isolated Re-renders
The `watch()` method triggers re-renders at the useForm hook level, affecting the entire form component. Use `useWatch()` in child components to isolate re-renders to only the components that need the watched value.
**Incorrect (watch at root causes entire form to re-render):**
```typescript
function CheckoutForm() {
const { register, watch, handleSubmit } = useForm()
const shippingMethod = watch('shippingMethod') // Every change re-renders entire form
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('shippingMethod')}>
<option value="standard">Standard</option>
<option value="express">Express</option>
</select>
<ShippingCost method={shippingMethod} />
<input {...register('address')} />
<input {...register('city')} />
</form>
)
}
```
**Correct (useWatch isolates re-render to child component):**
```typescript
function CheckoutForm() {
const { register, handleSubmit, control } = useForm()
return (
<form onSubmit={handleSubmit(onSubmit)}>
<select {...register('shippingMethod')}>
<option value="standard">Standard</option>
<option value="express">Express</option>
</select>
<ShippingCostDisplay control={control} /> {/* Only this re-renders */}
<input {...register('address')} />
<input {...register('city')} />
</form>
)
}
function ShippingCostDisplay({ control }: { control: Control<CheckoutFormData> }) {
const shippingMethod = useWatch({ control, name: 'shippingMethod' })
return <ShippingCost method={shippingMethod} />
}
```
**Push the subscription as deep as it will go.** The win is not `useWatch` over `watch` in itself — it is *where the subscription lives*. A `useWatch` at the top of the form re-renders the whole form exactly like `watch` does. Put it in the leaf that renders the value, and pass `control` down rather than the watched value; the sibling sections then never re-render. If you don't want to author a component for it, `<Watch>` does the same inline — see `sub-render-prop-components`.
Reference: [useWatch](https://react-hook-form.com/docs/usewatch)

View File

@@ -0,0 +1,54 @@
---
title: Watch Specific Fields Instead of Entire Form
impact: CRITICAL
impactDescription: reduces re-renders from N fields to 1 field change
tags: sub, watch, specific-fields, re-renders
---
## Watch Specific Fields Instead of Entire Form
Calling `watch()` without arguments subscribes to ALL form fields, causing re-renders on any field change. Always specify the field names you need.
**Incorrect (watches all fields, re-renders on any change):**
```typescript
function OrderForm() {
const { register, watch, handleSubmit } = useForm()
const formValues = watch() // Re-renders when ANY field changes
const total = calculateTotal(formValues.quantity, formValues.price)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customerName')} /> {/* Changes here trigger total recalc */}
<input {...register('email')} /> {/* Changes here trigger total recalc */}
<input {...register('quantity', { valueAsNumber: true })} />
<input {...register('price', { valueAsNumber: true })} />
<div>Total: ${total}</div>
</form>
)
}
```
**Correct (watches only needed fields):**
```typescript
function OrderForm() {
const { register, watch, handleSubmit } = useForm()
const [quantity, price] = watch(['quantity', 'price']) // Only re-renders when these change
const total = calculateTotal(quantity, price)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customerName')} /> {/* No re-render on change */}
<input {...register('email')} /> {/* No re-render on change */}
<input {...register('quantity', { valueAsNumber: true })} />
<input {...register('price', { valueAsNumber: true })} />
<div>Total: ${total}</div>
</form>
)
}
```
Reference: [useForm - watch](https://react-hook-form.com/docs/useform/watch)

View File

@@ -0,0 +1,62 @@
---
title: Use delayError to Debounce Rapid Error Display
impact: MEDIUM
impactDescription: reduces UI flicker during fast typing validation
tags: valid, delayError, debounce, user-experience
---
## Use delayError to Debounce Rapid Error Display
When using `onChange` mode, errors appear and disappear rapidly as users type. Use `delayError` to add a small delay, preventing UI flicker while still providing timely feedback.
**Incorrect (errors flash rapidly during typing):**
```typescript
function SearchForm() {
const { register, formState: { errors } } = useForm({
mode: 'onChange',
})
return (
<form>
<input {...register('query', { minLength: 3 })} />
{errors.query && <span>Min 3 characters</span>} {/* Flashes on/off rapidly */}
</form>
)
}
```
**Correct (error display debounced):**
```typescript
function SearchForm() {
const { register, formState: { errors } } = useForm({
mode: 'onChange',
delayError: 300, // 300ms delay before showing errors
})
return (
<form>
<input {...register('query', { minLength: 3 })} />
{errors.query && <span>Min 3 characters</span>} {/* Appears after 300ms delay */}
</form>
)
}
```
**When to use:**
- Real-time validation with `onChange` mode
- Fields with character count requirements
- Search inputs with minimum length
**Opting a single `setValue` into the delay (7.82+):** `setValue` accepts `delayError`, but it is a **boolean** — it opts that call into the debounce, and the duration still comes from `useForm({ delayError })`. Passing a number is a type error, even though the 7.82.0 release notes show `delayError: 500`:
```typescript
const { setValue } = useForm({ mode: 'onChange', delayError: 300 })
setValue('query', suggestion, { shouldValidate: true, delayError: true }) // debounced by 300ms
```
Without a form-level `delayError`, `{ delayError: true }` on `setValue` has nothing to debounce with and the error appears immediately.
Reference: [useForm - delayError](https://react-hook-form.com/docs/useform) · [useForm - setValue](https://react-hook-form.com/docs/useform/setvalue)

View File

@@ -0,0 +1,82 @@
---
title: Build the Validation Schema Once, Outside the Render Path
impact: HIGH
impactDescription: stops rebuilding the whole schema object on every keystroke
tags: valid, resolver, schema, zod, useMemo
---
## Build the Validation Schema Once, Outside the Render Path
There is no resolver cache in React Hook Form — `useForm` reassigns `control._options = props` on every render, and whatever resolver you passed is used as-is. The cost of an inline schema is the **construction**: `z.object({ … })` allocates a fresh validator tree every render, and under `mode: 'onChange'` that is once per keystroke, on top of the validation itself. Hoisting the schema to module scope makes it a one-time cost at import.
**Incorrect (a new schema object built on every render):**
```typescript
function InviteMemberForm() {
const { register, handleSubmit } = useForm<InviteFormValues>({
resolver: zodResolver(
z.object({
email: z.email('Enter a valid email address'),
role: z.enum(['admin', 'editor', 'viewer']),
}),
),
defaultValues: { email: '', role: 'viewer' },
})
return (
<form onSubmit={handleSubmit(sendInvite)}>
<input {...register('email')} />
</form>
)
}
```
**Correct (built once at module load):**
```typescript
const inviteSchema = z.object({
email: z.email('Enter a valid email address'),
role: z.enum(['admin', 'editor', 'viewer']),
})
function InviteMemberForm() {
const { register, handleSubmit } = useForm<InviteFormValues>({
resolver: zodResolver(inviteSchema),
defaultValues: { email: '', role: 'viewer' },
})
return (
<form onSubmit={handleSubmit(sendInvite)}>
<input {...register('email')} />
</form>
)
}
```
**When the schema genuinely depends on props or context**, hoist a factory instead of the schema and memoize the call — so it rebuilds when the input changes, not when the component renders:
```typescript
const createSeatSchema = (maxSeats: number) =>
z.object({
seats: z.number().int().max(maxSeats, `Your plan allows ${maxSeats} seats`),
})
function SeatAllocationForm({ maxSeats }: { maxSeats: number }) {
const schema = useMemo(() => createSeatSchema(maxSeats), [maxSeats])
const { register, handleSubmit } = useForm<SeatFormValues>({
resolver: zodResolver(schema),
defaultValues: { seats: 1 },
})
return (
<form onSubmit={handleSubmit(updateSeats)}>
<input type="number" {...register('seats', { valueAsNumber: true })} />
</form>
)
}
```
Prefer a schema-level `.refine()` over a factory when the rule depends on *other fields* rather than on props — cross-field rules don't need the schema rebuilt.
Reference: [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers)

View File

@@ -0,0 +1,85 @@
---
title: Surface Server Errors via setError('root.serverError', ...)
impact: HIGH
impactDescription: prevents lost server-side validation errors and unrecoverable form state
tags: valid, server-errors, setError, async, error-handling
---
## Surface Server Errors via setError('root.serverError', ...)
`handleSubmit` does not catch errors thrown inside async submit handlers — it logs them and silently leaves the form unrecoverable (`isSubmitting` stays `true` if you `throw`). The canonical pattern is to `try/catch` inside the submit handler and route API failures into `setError`. Use field-level `setError(name, ...)` when the server tells you which field is wrong; use `setError('root.serverError', ...)` for general failures (network error, 500, "Account is locked").
**Incorrect (server error is thrown, swallowed, and form is now stuck):**
```typescript
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) throw new Error('Login failed') // Lost: no UI feedback, isSubmitting stuck
redirect('/dashboard')
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
<input type="password" {...register('password')} />
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}
```
**Correct (server errors surfaced via setError, form stays recoverable):**
```typescript
function LoginForm() {
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>()
const onSubmit = async (data: LoginFormData) => {
clearErrors('root.serverError')
try {
const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const body = await res.json()
if (body.field === 'password') {
setError('password', { type: 'server', message: body.message })
} else {
setError('root.serverError', { type: 'server', message: body.message ?? 'Sign in failed' })
}
return
}
redirect('/dashboard')
} catch {
setError('root.serverError', { type: 'network', message: 'Network error — please retry' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root?.serverError && (
<div role="alert">{errors.root.serverError.message}</div>
)}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>Sign in</button>
</form>
)
}
```
**Key details:**
- Root-level errors live under `errors.root.{key}` — pick any key (`serverError`, `network`, `rateLimit`) and read it back the same way
- Root errors **persist across submissions** until you call `clearErrors('root.serverError')` — clear at the start of each submit, or rely on the next resolver pass to overwrite
- Always `try/catch` async submit handlers. `handleSubmit` will not surface thrown errors, and `isSubmitting` only resets when the handler returns (resolves), not when it throws — see also `formstate-async-submit-lifecycle`
Reference: [setError](https://react-hook-form.com/docs/useform/seterror) · [Discussion #9691 — Handle global/server errors](https://github.com/orgs/react-hook-form/discussions/9691)

View File

@@ -0,0 +1,68 @@
---
title: Handle the NaN valueAsNumber Produces for an Empty Input
impact: HIGH
impactDescription: prevents an optional number field that can never be left blank
tags: valid, valueAsNumber, setValueAs, NaN, optional-fields
---
## Handle the NaN valueAsNumber Produces for an Empty Input
`register('n', { valueAsNumber: true })` converts an empty string to **`NaN`**, not to `undefined` or `null`. This is deliberate — a fix that treated `NaN` as empty was reverted in 7.76.1 — so it is stable behaviour you have to design around rather than a bug to wait out.
It is invisible on a required field, where any complaint is the complaint you wanted. It breaks **optional** number fields: the user clears the box, the schema receives `NaN`, `z.number().optional()` rejects it, and the field can never be left blank. The error text ("expected number, received nan") points at the schema rather than at the conversion, so the cause is easy to miss.
**Incorrect (an optional field the user cannot clear):**
```typescript
const listingSchema = z.object({
title: z.string().min(1),
reservePrice: z.number().positive().optional(),
})
function ListingForm() {
const { register, handleSubmit } = useForm<ListingFormValues>({
resolver: zodResolver(listingSchema),
defaultValues: { title: '', reservePrice: undefined },
})
return (
<form onSubmit={handleSubmit(saveListing)}>
{/* Clearing the input yields NaN, which fails .optional() */}
<input type="number" {...register('reservePrice', { valueAsNumber: true })} />
</form>
)
}
```
**Correct (map empty to undefined with setValueAs):**
```typescript
const listingSchema = z.object({
title: z.string().min(1),
reservePrice: z.number().positive().optional(),
})
function ListingForm() {
const { register, handleSubmit } = useForm<ListingFormValues>({
resolver: zodResolver(listingSchema),
defaultValues: { title: '', reservePrice: undefined },
})
return (
<form onSubmit={handleSubmit(saveListing)}>
<input
type="number"
{...register('reservePrice', {
setValueAs: (value) => (value === '' ? undefined : Number(value)),
})}
/>
</form>
)
}
```
`setValueAs` and `valueAsNumber` are mutually exclusive — supplying `setValueAs` replaces the built-in conversion, which is exactly what you want here.
For a **required** number, `valueAsNumber: true` is fine: `NaN` fails validation, which is the correct outcome. Reserve `setValueAs` for fields that are genuinely allowed to be empty, and prefer it over `z.coerce.number()`, which turns `''` into `0` and would silently record a reserve price of zero.
Reference: [register - valueAsNumber](https://react-hook-form.com/docs/useform/register)

View File

@@ -0,0 +1,277 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast follows the project base.** Use `toast` from the `toast` component for
Base UI projects. Use `toast()` from `sonner` for Radix and React Aria
projects.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### Chat & Messaging → [chat.md](./rules/chat.md)
- **Chat UI composes the chat primitives.** Conversations use `MessageScroller`, rows use `Message`, surfaces use `Bubble`. Never hand-rolled bubble `div`s or a raw scroll container.
- **`MessageScroller` owns scroll behavior.** Streaming follow, anchoring, and jump-to-latest (`MessageScrollerButton`) are built in. Don't write a `useStickToBottom`/`ResizeObserver` hook.
- **Attachments use `Attachment`; system notes and dividers use `Marker`.** Not `Item` cards or `Separator` + a label.
### CLI
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `toast` (Base UI), `sonner` (Radix/Aria), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
| Chat / conversation UI | `MessageScroller`, `Message`, `Bubble`, `Attachment`, `Marker` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components** — `npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
# Apply a preset to an existing project.
npx shadcn@latest apply a2r6bw
npx shadcn@latest apply a2r6bw --only theme
npx shadcn@latest apply a2r6bw --only font
npx shadcn@latest apply a2r6bw --only theme,font
# Inspect preset codes and project preset state.
npx shadcn@latest preset decode a2r6bw
npx shadcn@latest preset url a2r6bw
npx shadcn@latest preset open a2r6bw
npx shadcn@latest preset resolve
npx shadcn@latest preset resolve --json
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add owner/repo/item
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
npx shadcn@latest add owner/repo/item --dry-run
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
npx shadcn@latest search owner/repo -q "login"
npx shadcn@latest search # all configured registries
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
npx shadcn@latest view owner/repo/item
```
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/chat.md](./rules/chat.md) — MessageScroller, Message, Bubble, Attachment, Marker; streaming, anchoring, jump-to-latest
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
- [customization.md](./customization.md) — Theming, CSS variables, extending components

View File

@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,290 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `apply` — Apply a preset to an existing project
```bash
npx shadcn@latest apply [preset] [options]
```
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------ | ------- |
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--silent` | `-s` | Mute output | `false` |
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# Works with public GitHub registries too.
npx shadcn@latest add owner/repo/item --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search [registries...] [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
and registry catalog URLs. Without `-q`, lists all items. When no registries are
passed, searches every registry configured in `components.json`.
| Flag | Short | Description | Default |
| ------------------- | ----- | ------------------------------------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
| `--limit <number>` | `-l` | Max items to display | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--json` | | Output as JSON | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Examples:
`npx shadcn@latest view @shadcn/button`,
`npx shadcn@latest view owner/repo/item`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
For authoring rules, `include`, item definitions, `registryDependencies`, and
GitHub registry behavior, see [registry.md](./registry.md).
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset nova` or `--preset lyra`
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
## Switching Presets
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.

View File

@@ -0,0 +1,209 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest apply --preset a2r6bw
# Positional shorthand also works.
npx shadcn@latest apply a2r6bw
# Switch to a named preset and overwrite existing components.
npx shadcn@latest apply --preset nova
# Preserve existing components instead.
npx shadcn@latest init --preset nova --force --no-reinstall
# Use a custom theme URL.
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">
Click
</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="mx-auto max-w-md">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

Some files were not shown because too many files have changed in this diff Show More