docs: add Trellis planning and project specs
This commit is contained in:
315
.trellis/spec/shared/code-quality.md
Normal file
315
.trellis/spec/shared/code-quality.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# Code Quality Guidelines
|
||||
|
||||
> Mandatory code quality rules for all Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## No Non-Null Assertions
|
||||
|
||||
**NEVER** use non-null assertions (`!`). They bypass TypeScript's null checking and lead to runtime errors.
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
const name = user!.name;
|
||||
const value = data!.items![0]!;
|
||||
|
||||
// REQUIRED - Use explicit checks
|
||||
const user = getUser();
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const name = user.name;
|
||||
|
||||
// REQUIRED - Use optional chaining with fallback
|
||||
const value = data?.items?.[0] ?? defaultValue;
|
||||
|
||||
// REQUIRED - Use local variable after null check
|
||||
const project = getProject(id);
|
||||
if (!project) {
|
||||
return { success: false, reason: 'Project not found' };
|
||||
}
|
||||
const projectName = project.name;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `any` Type
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD - Use proper types
|
||||
function process(data: ProcessInput) { ... }
|
||||
|
||||
// GOOD - Use unknown for truly unknown data
|
||||
function parseJSON(input: string): unknown {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
|
||||
// BAD - any in cache updates
|
||||
queryClient.setQueryData(['users'], (old: any) => ...);
|
||||
|
||||
// GOOD - Properly typed cache updates
|
||||
queryClient.setQueryData<UserListData>(['users'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, items: old.items.filter((u) => u.id !== deletedId) };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// FORBIDDEN
|
||||
// @ts-expect-error - field exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// @ts-ignore
|
||||
doSomething(invalidArg);
|
||||
|
||||
// REQUIRED - Fix the type issue at the source
|
||||
// If a field exists at runtime but not in types, update the type definition.
|
||||
doSomething(validArg);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No `console.log`
|
||||
|
||||
Use structured logging instead of `console.log`. This applies to both frontend and backend code.
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
console.log('User created:', userId);
|
||||
console.log('Error:', error);
|
||||
|
||||
// GOOD - Backend: use structured logger
|
||||
logger.info('user_created', { userId });
|
||||
logger.error('operation_failed', { error, operationId });
|
||||
|
||||
// GOOD - Frontend: remove debug logs before commit
|
||||
// Use browser dev tools for debugging, not console.log
|
||||
```
|
||||
|
||||
**Exception**: `console.warn` and `console.error` are acceptable in frontend code for development-time warnings that will not appear in production.
|
||||
|
||||
---
|
||||
|
||||
## Import Ordering
|
||||
|
||||
Organize imports in this order, separated by blank lines:
|
||||
|
||||
```typescript
|
||||
// 1. Node built-ins
|
||||
import path from 'node:path';
|
||||
|
||||
// 2. External packages
|
||||
import { z } from 'zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// 3. Internal workspace packages
|
||||
import type { User } from '@your-app/api/modules/users/types';
|
||||
|
||||
// 4. Local imports (relative paths)
|
||||
import { formatDate } from './utils';
|
||||
import type { Props } from './types';
|
||||
```
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// BAD - Mixed imports without type annotation
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
| --------------- | --------------------------- | --------------------------- |
|
||||
| React Component | PascalCase | `UserProfile.tsx` |
|
||||
| Hook | camelCase with `use` prefix | `useProject.ts` |
|
||||
| Utility | kebab-case | `date-utils.ts` |
|
||||
| Type file | kebab-case or `types.ts` | `types.ts`, `user-types.ts` |
|
||||
| Test file | Same name + `.test` | `date-utils.test.ts` |
|
||||
| Directory | kebab-case | `user-profile/` |
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
| Type | Convention | Example |
|
||||
| -------------- | ------------------------------------------- | ---------------------------------- |
|
||||
| Variable | camelCase | `userName`, `isActive` |
|
||||
| Constant | SCREAMING_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||
| Function | camelCase | `getUserById` |
|
||||
| Class | PascalCase | `UserService` |
|
||||
| Type/Interface | PascalCase | `UserInput`, `ProjectOutput` |
|
||||
| Enum | PascalCase (type), SCREAMING_SNAKE (values) | `enum Status { ACTIVE, INACTIVE }` |
|
||||
|
||||
### Boolean Variables
|
||||
|
||||
Use `is`, `has`, `should`, `can` prefixes:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
const isLoading = true;
|
||||
const hasPermission = user.role === 'admin';
|
||||
const shouldRefresh = Date.now() > expiresAt;
|
||||
const canEdit = isOwner || hasPermission;
|
||||
|
||||
// BAD
|
||||
const loading = true;
|
||||
const permission = user.role === 'admin';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Never Swallow Errors
|
||||
|
||||
```typescript
|
||||
// BAD - Silent failure
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (e) {
|
||||
// nothing
|
||||
}
|
||||
|
||||
// GOOD - Log and handle
|
||||
try {
|
||||
await dangerousOperation();
|
||||
} catch (error) {
|
||||
logger.error('operation_failed', { error });
|
||||
throw new AppError('Operation failed', 'OPERATION_FAILED');
|
||||
}
|
||||
```
|
||||
|
||||
### Consistent Error Response Format
|
||||
|
||||
All API responses must use the standard `success` + `reason` format:
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
return {
|
||||
success: true,
|
||||
reason: 'Operation completed successfully',
|
||||
data: result,
|
||||
};
|
||||
|
||||
// Error
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Insufficient permissions to perform this action',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dead Code Elimination
|
||||
|
||||
- Remove unused imports (Biome enforces this automatically)
|
||||
- Remove commented-out code blocks
|
||||
- Remove unused variables, functions, and types
|
||||
- Remove unreachable code after `return`, `throw`, `break`, `continue`
|
||||
|
||||
```typescript
|
||||
// BAD - Dead code
|
||||
function processOrder(order: Order) {
|
||||
// const oldLogic = order.items.map(...);
|
||||
const result = newLogic(order);
|
||||
return result;
|
||||
cleanup(); // unreachable
|
||||
}
|
||||
|
||||
// GOOD - Clean
|
||||
function processOrder(order: Order) {
|
||||
return newLogic(order);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lint and Type Check Before Commit
|
||||
|
||||
```bash
|
||||
# MUST pass before every commit
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
|
||||
# Production build check (catches additional issues)
|
||||
pnpm build
|
||||
|
||||
# Or combined
|
||||
pnpm lint && pnpm type-check && pnpm build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test File Location
|
||||
|
||||
```
|
||||
src/
|
||||
__tests__/ # Integration tests
|
||||
api.test.ts
|
||||
app/
|
||||
feature/
|
||||
page.tsx
|
||||
page.test.tsx # Co-located test (when appropriate)
|
||||
```
|
||||
|
||||
### Test Structure (AAA Pattern)
|
||||
|
||||
```typescript
|
||||
describe('OrderService', () => {
|
||||
describe('createOrder', () => {
|
||||
it('should create an order with valid input', async () => {
|
||||
// Arrange
|
||||
const input = { items: [{ productId: '1', quantity: 2 }] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.order.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should reject empty order', async () => {
|
||||
// Arrange
|
||||
const input = { items: [] };
|
||||
|
||||
// Act
|
||||
const result = await createOrder(input);
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Rule | Reason |
|
||||
| ------------------------------ | ------------------- |
|
||||
| No `!` assertions | Runtime errors |
|
||||
| No `any` type | Type safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| No `console.log` | Use structured logs |
|
||||
| Lint + typecheck before commit | Consistent code |
|
||||
| Structured errors | Consistent handling |
|
||||
| Never swallow errors | Debuggability |
|
||||
| Remove dead code | Maintainability |
|
||||
173
.trellis/spec/shared/dependencies.md
Normal file
173
.trellis/spec/shared/dependencies.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Dependencies & Versions
|
||||
|
||||
> Adjust versions to your project. These represent a known-working combination as of the time of writing. Pin or widen ranges to match your stability requirements.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
| Dependency | Version | Description |
|
||||
|------------|---------|-------------|
|
||||
| Node.js | >=20 | JavaScript runtime |
|
||||
| pnpm | ^10.x | Package manager |
|
||||
|
||||
---
|
||||
|
||||
## Core Framework
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next | ^15.x | React framework for production |
|
||||
| react | ^19.x | UI library |
|
||||
| react-dom | ^19.x | React DOM renderer |
|
||||
| typescript | ^5.x | TypeScript language |
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### API Layer
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| hono | ^4.x | Lightweight web framework |
|
||||
| @orpc/server | ^1.x | oRPC server implementation |
|
||||
| @orpc/client | ^1.x | oRPC client |
|
||||
| @orpc/zod | ^1.x | oRPC Zod integration |
|
||||
| @orpc/openapi | ^1.x | OpenAPI schema generation |
|
||||
| zod | ^4.x | Schema validation |
|
||||
|
||||
### Database
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| drizzle-orm | ^0.44.x | TypeScript ORM |
|
||||
| drizzle-kit | ^0.31.x | Drizzle CLI tools |
|
||||
| drizzle-zod | ^0.8.x | Drizzle + Zod integration |
|
||||
| pg | ^8.x | PostgreSQL client |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| better-auth | ^1.x | Authentication library |
|
||||
|
||||
### Caching & Queue
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @upstash/redis | ^1.x | Redis client (Upstash) |
|
||||
| @upstash/qstash | ^2.x | Message queue |
|
||||
|
||||
---
|
||||
|
||||
## AI Integration
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| ai | ^5.x | Vercel AI SDK core |
|
||||
| @ai-sdk/react | ^2.x | AI SDK React hooks |
|
||||
| @ai-sdk/openai | ^2.x | OpenAI provider |
|
||||
| @ai-sdk/anthropic | ^2.x | Anthropic provider |
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### UI Components
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @radix-ui/* | latest | Headless UI primitives |
|
||||
| lucide-react | ^0.x | Icon library |
|
||||
| cmdk | ^1.x | Command palette |
|
||||
| sonner | ^2.x | Toast notifications |
|
||||
|
||||
### Styling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| tailwindcss | ^4.x | Utility-first CSS (v4 config format) |
|
||||
| @tailwindcss/postcss | ^4.x | PostCSS plugin |
|
||||
| tailwind-merge | ^3.x | Tailwind class merging |
|
||||
| class-variance-authority | ^0.7.x | Variant management |
|
||||
| clsx | ^2.x | Class name utility |
|
||||
|
||||
### State Management
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @tanstack/react-query | ^5.x | Data fetching & caching |
|
||||
| @orpc/tanstack-query | ^1.x | oRPC + React Query bridge |
|
||||
| nuqs | ^2.x | URL state management |
|
||||
| react-hook-form | ^7.x | Form state management |
|
||||
| @hookform/resolvers | ^5.x | Form validation resolvers |
|
||||
|
||||
### Internationalization
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| next-intl | ^4.x | Next.js i18n |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| date-fns | ^4.x | Date utilities |
|
||||
| es-toolkit | ^1.x | Utility functions |
|
||||
| nanoid | ^5.x | ID generation |
|
||||
| p-limit | ^7.x | Concurrency control |
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Logging
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @sentry/nextjs | ^10.x | Error tracking |
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
|
||||
### Build & Bundling
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| turbo | ^2.x | Monorepo build system |
|
||||
| tsx | ^4.x | TypeScript executor |
|
||||
|
||||
### Code Quality
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @biomejs/biome | ^2.x | Linter & formatter |
|
||||
| husky | ^9.x | Git hooks |
|
||||
|
||||
### Testing
|
||||
|
||||
| Package | Version | Description |
|
||||
|---------|---------|-------------|
|
||||
| @playwright/test | ^1.x | E2E testing |
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **React 19**: Major version with breaking changes from React 18
|
||||
2. **Next.js 15**: App Router is the primary routing pattern
|
||||
3. **TailwindCSS 4**: Uses the new v4 configuration format (not `tailwind.config.js`)
|
||||
4. **Zod 4**: Latest version with improved TypeScript support
|
||||
5. **Monorepo**: Use `@your-app/*` for internal workspace package references
|
||||
|
||||
---
|
||||
|
||||
## Updating Dependencies
|
||||
|
||||
When updating dependencies:
|
||||
|
||||
1. Check compatibility with React 19 and Next.js 15
|
||||
2. Update pnpm overrides if changing React or Drizzle versions
|
||||
3. Run `pnpm install` from the root directory
|
||||
4. Run `pnpm type-check` to verify TypeScript compatibility
|
||||
5. Run `pnpm build` to ensure production build works
|
||||
66
.trellis/spec/shared/index.md
Normal file
66
.trellis/spec/shared/index.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Shared Development Guidelines
|
||||
|
||||
> These guidelines apply to all Next.js full-stack applications using this architecture.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Files
|
||||
|
||||
| File | Description | When to Read |
|
||||
| -------------------------------------- | ------------------------------------ | ----------------------- |
|
||||
| [code-quality.md](./code-quality.md) | Code quality mandatory rules | Always |
|
||||
| [typescript.md](./typescript.md) | TypeScript best practices | Type-related decisions |
|
||||
| [dependencies.md](./dependencies.md) | Dependency versions and constraints | Adding/updating deps |
|
||||
|
||||
---
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
| Task | File |
|
||||
| --------------------------- | -------------------------------------- |
|
||||
| Code quality rules | [code-quality.md](./code-quality.md) |
|
||||
| Type annotations | [typescript.md](./typescript.md) |
|
||||
| Dependency management | [dependencies.md](./dependencies.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Rules (MANDATORY)
|
||||
|
||||
| Rule | File |
|
||||
| ----------------------------------------- | -------------------------------------- |
|
||||
| No non-null assertions (`!`) | [code-quality.md](./code-quality.md) |
|
||||
| No `any` type | [code-quality.md](./code-quality.md) |
|
||||
| No `@ts-expect-error` / `@ts-ignore` | [code-quality.md](./code-quality.md) |
|
||||
| No `console.log` (use structured logging) | [code-quality.md](./code-quality.md) |
|
||||
| Zod-first type definitions | [typescript.md](./typescript.md) |
|
||||
| Import types from backend, never redefine | [typescript.md](./typescript.md) |
|
||||
| Standard response format (`success` + `reason`) | [typescript.md](./typescript.md) |
|
||||
|
||||
---
|
||||
|
||||
## Before Every Commit
|
||||
|
||||
- [ ] `pnpm lint` - 0 errors
|
||||
- [ ] `pnpm type-check` - 0 errors
|
||||
- [ ] `pnpm build` - production build succeeds
|
||||
- [ ] No `any` types in new code
|
||||
- [ ] No non-null assertions (`!`)
|
||||
- [ ] No `@ts-expect-error` or `@ts-ignore` comments
|
||||
- [ ] No `console.log` statements (use `logger`)
|
||||
- [ ] Tests pass (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
- [ ] Types are explicit, not `any`
|
||||
- [ ] API inputs/outputs have Zod schemas
|
||||
- [ ] Error handling returns structured responses
|
||||
- [ ] No duplicate type definitions (import from source of truth)
|
||||
- [ ] Naming follows conventions (files: kebab-case, components: PascalCase)
|
||||
- [ ] Unused imports and dead code removed
|
||||
- [ ] No swallowed errors (silent `catch` blocks)
|
||||
|
||||
---
|
||||
|
||||
**Language**: All documentation must be written in **English**.
|
||||
441
.trellis/spec/shared/typescript.md
Normal file
441
.trellis/spec/shared/typescript.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# TypeScript Best Practices
|
||||
|
||||
> TypeScript guidelines for Next.js full-stack applications.
|
||||
|
||||
---
|
||||
|
||||
## Zod-First Type Definitions
|
||||
|
||||
Define Zod schemas first, then infer TypeScript types from them. Never define types manually when a Zod schema exists.
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// 1. Define the schema (single source of truth)
|
||||
export const createUserInputSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']),
|
||||
});
|
||||
|
||||
export const createUserOutputSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// 2. Derive types from schemas
|
||||
export type CreateUserInput = z.infer<typeof createUserInputSchema>;
|
||||
export type CreateUserOutput = z.infer<typeof createUserOutputSchema>;
|
||||
|
||||
// BAD - Manual type that duplicates schema
|
||||
interface CreateUserInput {
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Base Schemas
|
||||
|
||||
```typescript
|
||||
const paginationSchema = z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
const timestampSchema = z.object({
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// Compose into larger schemas
|
||||
export const listOrdersInputSchema = paginationSchema.extend({
|
||||
status: orderStatusZodSchema.optional(),
|
||||
customerId: z.string().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Inference from API
|
||||
|
||||
Import types from the backend or infer them from the API client. Never redefine backend types on the frontend.
|
||||
|
||||
### Import from Backend Package
|
||||
|
||||
```typescript
|
||||
// GOOD - Import from the API package
|
||||
import type { User, Order } from '@your-app/api/modules/users/types';
|
||||
import type { OrderStatus } from '@your-app/api/modules/orders/types';
|
||||
|
||||
// BAD - Redefining types that exist in backend
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Infer from API Client
|
||||
|
||||
```typescript
|
||||
import { orpcClient } from '@/lib/orpc';
|
||||
|
||||
// Infer the response type from the API client
|
||||
type UsersResponse = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
// Infer a single item type from array response
|
||||
type User = UsersResponse['items'][number];
|
||||
|
||||
// Infer input types
|
||||
type CreateUserInput = Parameters<typeof orpcClient.users.create>[0];
|
||||
```
|
||||
|
||||
### Type Inference in Hooks
|
||||
|
||||
```typescript
|
||||
// The return type is automatically inferred from oRPC
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => orpcClient.users.list(),
|
||||
});
|
||||
}
|
||||
|
||||
// For complex transformations, use explicit inference
|
||||
type UserListData = Awaited<ReturnType<typeof orpcClient.users.list>>;
|
||||
|
||||
export function useFormattedUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'formatted'],
|
||||
queryFn: async () => {
|
||||
const data = await orpcClient.users.list();
|
||||
return transformUsers(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
Use discriminated unions for types that can be one of several shapes. Use strict equality (`=== true`) for narrowing.
|
||||
|
||||
### TypeScript Discriminated Union
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
const result: Result<User> = doSomething();
|
||||
|
||||
// CORRECT: Use === true for narrowing
|
||||
if (result.success === true) {
|
||||
console.log(result.data); // TypeScript knows data exists
|
||||
} else {
|
||||
console.log(result.error); // TypeScript knows error exists
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Discriminated Union
|
||||
|
||||
```typescript
|
||||
export const notificationSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('email'),
|
||||
recipient: z.string().email(),
|
||||
subject: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sms'),
|
||||
phoneNumber: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('push'),
|
||||
deviceToken: z.string(),
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
type Notification = z.infer<typeof notificationSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Patterns
|
||||
|
||||
### Generic Result Type
|
||||
|
||||
```typescript
|
||||
type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
function createResult<T>(data: T): Result<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
```
|
||||
|
||||
### Generic Paginated Response
|
||||
|
||||
```typescript
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type UserListResponse = PaginatedResponse<User>;
|
||||
```
|
||||
|
||||
### Generic with Constraints
|
||||
|
||||
```typescript
|
||||
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return obj[key];
|
||||
}
|
||||
```
|
||||
|
||||
### Common Utility Types
|
||||
|
||||
```typescript
|
||||
// Extract array element type
|
||||
type ArrayElement<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
// Make specific properties optional
|
||||
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
// Make specific properties required
|
||||
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
All API responses must include `success` and `reason` fields.
|
||||
|
||||
```typescript
|
||||
// Output schema pattern
|
||||
export const operationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
reason: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
// Success response
|
||||
return {
|
||||
success: true,
|
||||
reason: 'User created successfully',
|
||||
user: { id, name, email },
|
||||
};
|
||||
|
||||
// Error response
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Email address is already in use',
|
||||
};
|
||||
```
|
||||
|
||||
### Batch Operation Response
|
||||
|
||||
```typescript
|
||||
export const batchOperationResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
total: z.number(),
|
||||
processed: z.number(),
|
||||
failed: z.number(),
|
||||
errors: z.array(z.object({
|
||||
itemId: z.string(),
|
||||
error: z.string(),
|
||||
})).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
### No `any`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
function process(data: any) { ... }
|
||||
|
||||
// GOOD
|
||||
function process(data: unknown) { ... }
|
||||
function process(data: ProcessInput) { ... }
|
||||
```
|
||||
|
||||
### No Non-null Assertion
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
const name = user!.name;
|
||||
const first = items[0]!;
|
||||
|
||||
// GOOD
|
||||
if (user) {
|
||||
const name = user.name;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
if (!first) {
|
||||
return { success: false, reason: 'No items found' };
|
||||
}
|
||||
```
|
||||
|
||||
### No `@ts-expect-error` / `@ts-ignore`
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
// @ts-expect-error - customField exists at runtime
|
||||
const value = user.customField;
|
||||
|
||||
// GOOD - Update the type definition instead
|
||||
interface User {
|
||||
customField: string;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### No Type Assertions Without Validation
|
||||
|
||||
```typescript
|
||||
// BAD - Blind assertion
|
||||
const user = data as User;
|
||||
|
||||
// GOOD - Runtime validation with Zod
|
||||
const user = userSchema.parse(data);
|
||||
|
||||
// GOOD - Type guard
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'email' in data
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Imports
|
||||
|
||||
Always use `import type` for type-only imports:
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
import type { User, Project } from './types';
|
||||
import { createUser } from './procedures';
|
||||
|
||||
// Also acceptable
|
||||
import { type User, createUser } from './types';
|
||||
|
||||
// BAD
|
||||
import { User, createUser } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Return Types for Exports
|
||||
|
||||
Always annotate explicit return types on exported functions:
|
||||
|
||||
```typescript
|
||||
// BAD - Implicit return type
|
||||
export function getUser(id: string) {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
|
||||
// GOOD - Explicit return type
|
||||
export function getUser(id: string): Promise<User | undefined> {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Ensure strict mode is enabled:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drizzle Type Inference
|
||||
|
||||
```typescript
|
||||
// Infer types from Drizzle tables
|
||||
type User = typeof userTable.$inferSelect;
|
||||
type NewUser = typeof userTable.$inferInsert;
|
||||
|
||||
// Combine with Zod via drizzle-zod
|
||||
import { createSelectSchema, createInsertSchema } from 'drizzle-zod';
|
||||
const userSelectSchema = createSelectSchema(userTable);
|
||||
const userInsertSchema = createInsertSchema(userTable);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## View Model Types
|
||||
|
||||
When the frontend needs computed properties, extend backend types rather than redefining them:
|
||||
|
||||
```typescript
|
||||
import type { Order } from '@your-app/api/modules/orders/types';
|
||||
|
||||
export interface OrderViewModel extends Order {
|
||||
formattedTotal: string;
|
||||
statusLabel: string;
|
||||
isEditable: boolean;
|
||||
}
|
||||
|
||||
export function toOrderViewModel(order: Order): OrderViewModel {
|
||||
return {
|
||||
...order,
|
||||
formattedTotal: formatCurrency(order.total),
|
||||
statusLabel: getStatusLabel(order.status),
|
||||
isEditable: order.status === 'draft',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Practice | Reason |
|
||||
| --------------------------- | ----------------------------- |
|
||||
| Zod-first types | Single source of truth |
|
||||
| Import, don't redefine | No type drift |
|
||||
| `=== true` for unions | Proper narrowing |
|
||||
| Generics for reuse | DRY, type-safe |
|
||||
| `success` + `reason` format | Consistent API responses |
|
||||
| No `any` | Type safety |
|
||||
| No `!` assertions | Runtime safety |
|
||||
| No `@ts-expect-error` | Masks real issues |
|
||||
| `import type` | Clear separation, tree-shake |
|
||||
| Explicit return types | Documentation, catch errors |
|
||||
Reference in New Issue
Block a user