361 lines
10 KiB
Markdown
361 lines
10 KiB
Markdown
|
|
# TypeScript & Zod Best Practices — MeshiTrack
|
||
|
|
|
||
|
|
> Instruction file for TypeScript configuration, shared types, and Zod validation schemas in the monorepo.
|
||
|
|
|
||
|
|
## TypeScript Configuration
|
||
|
|
|
||
|
|
### Strict mode everywhere
|
||
|
|
|
||
|
|
All packages use `strict: true` (via `tsconfig.base.json`). This enables:
|
||
|
|
|
||
|
|
- `strictNullChecks` — forces handling of `null`/`undefined`
|
||
|
|
- `noImplicitAny` — requires explicit types when inference fails
|
||
|
|
- `strictPropertyInitialization` — ensures class properties are initialized
|
||
|
|
|
||
|
|
### Project-specific overrides
|
||
|
|
|
||
|
|
```json
|
||
|
|
// packages/api/tsconfig.json — inherits ESM from base
|
||
|
|
{
|
||
|
|
"extends": "../../tsconfig.base.json",
|
||
|
|
"compilerOptions": {
|
||
|
|
"outDir": "./dist",
|
||
|
|
"rootDir": "./src",
|
||
|
|
"target": "ES2022"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// packages/web/tsconfig.json — bundler module resolution for Next.js
|
||
|
|
{
|
||
|
|
"extends": "../../tsconfig.base.json",
|
||
|
|
"compilerOptions": {
|
||
|
|
"module": "esnext",
|
||
|
|
"moduleResolution": "bundler",
|
||
|
|
"verbatimModuleSyntax": false,
|
||
|
|
"jsx": "preserve",
|
||
|
|
"noEmit": true,
|
||
|
|
"paths": {
|
||
|
|
"@/*": ["./src/*"],
|
||
|
|
"@meshitrack/shared": ["../shared/src"]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// packages/shared/tsconfig.json
|
||
|
|
{
|
||
|
|
"extends": "../../tsconfig.base.json",
|
||
|
|
"compilerOptions": {
|
||
|
|
"outDir": "./dist",
|
||
|
|
"rootDir": "./src",
|
||
|
|
"declaration": true,
|
||
|
|
"declarationMap": true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ESM-first module system
|
||
|
|
|
||
|
|
All packages use `"type": "module"` and `"module": "nodenext"` (from base). Key rules:
|
||
|
|
|
||
|
|
- **Always use `.js` extensions** on relative imports (TypeScript resolves `.ts` from `.js` in nodenext)
|
||
|
|
- **Use `import type` for type-only imports** (`verbatimModuleSyntax: true` enforces this)
|
||
|
|
- **No `require()`** — use `import` exclusively
|
||
|
|
- **No `esModuleInterop`** — use namespace imports for CJS packages if needed
|
||
|
|
|
||
|
|
## Type Design Principles
|
||
|
|
|
||
|
|
### 1. Types represent domain concepts
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Good: clearly represents the domain
|
||
|
|
export interface Product {
|
||
|
|
id: string;
|
||
|
|
householdId: string;
|
||
|
|
name: string;
|
||
|
|
nutrition: NutritionInfo;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bad: generic/vague naming
|
||
|
|
export interface Item {
|
||
|
|
id: string;
|
||
|
|
hId: string;
|
||
|
|
n: string;
|
||
|
|
data: any;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Use enums for fixed sets of values
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export enum ProductCategory {
|
||
|
|
DAIRY = 'dairy',
|
||
|
|
MEAT = 'meat',
|
||
|
|
VEGETABLES = 'vegetables',
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use string values for readability in DB and API responses
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Use discriminated unions for status-dependent data
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
export type PantryItemState =
|
||
|
|
| { status: 'sealed'; purchaseDate: Date }
|
||
|
|
| { status: 'opened'; purchaseDate: Date; openedDate: Date }
|
||
|
|
| { status: 'prepared'; purchaseDate: Date; openedDate: Date; preparedDate: Date }
|
||
|
|
| { status: 'consumed'; consumedDate: Date }
|
||
|
|
| { status: 'discarded'; discardedDate: Date; reason?: string };
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Use `Pick`, `Omit`, `Partial` for derived types
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Create DTO from entity
|
||
|
|
export type CreateProductInput = Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'createdBy'>;
|
||
|
|
export type UpdateProductInput = Partial<CreateProductInput>;
|
||
|
|
|
||
|
|
// API response (without internal fields)
|
||
|
|
export type ProductResponse = Omit<Product, 'deletedAt'>;
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Use branded types for IDs (optional but recommended)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Prevents accidentally passing a ProductId where a HouseholdId is expected
|
||
|
|
declare const __brand: unique symbol;
|
||
|
|
type Brand<T, B> = T & { [__brand]: B };
|
||
|
|
|
||
|
|
export type ProductId = Brand<string, 'ProductId'>;
|
||
|
|
export type HouseholdId = Brand<string, 'HouseholdId'>;
|
||
|
|
export type UserId = Brand<string, 'UserId'>;
|
||
|
|
```
|
||
|
|
|
||
|
|
### 6. Never use `any` — use `unknown` if the type is truly unknown
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Bad
|
||
|
|
function parse(data: any): Product { ... }
|
||
|
|
|
||
|
|
// Good
|
||
|
|
function parse(data: unknown): Product {
|
||
|
|
// Validate/narrow first
|
||
|
|
const validated = ProductSchema.parse(data);
|
||
|
|
return validated;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Shared Package Organization
|
||
|
|
|
||
|
|
```
|
||
|
|
packages/shared/src/
|
||
|
|
├── index.ts # Re-exports everything
|
||
|
|
├── types/
|
||
|
|
│ ├── index.ts
|
||
|
|
│ ├── product.ts # Product, NutritionInfo
|
||
|
|
│ ├── recipe.ts # Recipe, RecipeIngredient, RecipeStep
|
||
|
|
│ ├── pantry.ts # PantryItem, FreshnessEstimate
|
||
|
|
│ ├── meal-plan.ts # MealPlan, PlannedMeal
|
||
|
|
│ ├── shopping-list.ts # ShoppingList, ShoppingItem
|
||
|
|
│ ├── store.ts # Store
|
||
|
|
│ ├── price.ts # PriceRecord
|
||
|
|
│ ├── user.ts # User, Household
|
||
|
|
│ ├── freshness.ts # FreshnessRule
|
||
|
|
│ └── common.ts # PaginatedResponse, ApiError
|
||
|
|
├── enums/
|
||
|
|
│ ├── index.ts
|
||
|
|
│ ├── product.enums.ts # ProductCategory, ServingUnit, ProductSource
|
||
|
|
│ ├── pantry.enums.ts # StorageLocation, ItemStatus, FreshnessUrgency
|
||
|
|
│ ├── recipe.enums.ts # NutritionWarning
|
||
|
|
│ ├── meal-plan.enums.ts # MealType, MealPlanStatus
|
||
|
|
│ └── roles.enums.ts # HouseholdRole
|
||
|
|
├── validation/
|
||
|
|
│ ├── index.ts
|
||
|
|
│ ├── product.schemas.ts
|
||
|
|
│ ├── recipe.schemas.ts
|
||
|
|
│ ├── pantry.schemas.ts
|
||
|
|
│ └── ...
|
||
|
|
└── utils/
|
||
|
|
├── index.ts
|
||
|
|
├── unit-conversion.ts # Serving unit conversions
|
||
|
|
└── nutrition.ts # Nutrition calculation helpers
|
||
|
|
```
|
||
|
|
|
||
|
|
## Zod Validation Schemas
|
||
|
|
|
||
|
|
### Co-locate schemas with types
|
||
|
|
|
||
|
|
Each type file has a corresponding validation file:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// validation/product.schemas.ts
|
||
|
|
import { z } from 'zod/v4';
|
||
|
|
import { ProductCategory, ServingUnit, ProductSource } from '../enums/index.js';
|
||
|
|
|
||
|
|
// Nutrition info sub-schema
|
||
|
|
export const NutritionInfoSchema = z.object({
|
||
|
|
calories: z.number().nonnegative(),
|
||
|
|
protein: z.number().nonnegative(),
|
||
|
|
carbs: z.number().nonnegative(),
|
||
|
|
fat: z.number().nonnegative(),
|
||
|
|
fiber: z.number().nonnegative().optional(),
|
||
|
|
sugar: z.number().nonnegative().optional(),
|
||
|
|
sodium: z.number().nonnegative().optional(),
|
||
|
|
saturatedFat: z.number().nonnegative().optional(),
|
||
|
|
cholesterol: z.number().nonnegative().optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Create product schema
|
||
|
|
export const CreateProductSchema = z.object({
|
||
|
|
name: z.string().min(1).max(200).trim(),
|
||
|
|
brand: z.string().max(200).trim().optional(),
|
||
|
|
barcode: z.string().max(50).optional(),
|
||
|
|
category: z.enum(ProductCategory),
|
||
|
|
servingSize: z.number().positive(),
|
||
|
|
servingUnit: z.enum(ServingUnit),
|
||
|
|
nutrition: NutritionInfoSchema,
|
||
|
|
tags: z.array(z.string().max(50)).max(20).default([]),
|
||
|
|
imageUrl: z.url().optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Update product schema (all fields optional)
|
||
|
|
export const UpdateProductSchema = CreateProductSchema.partial();
|
||
|
|
|
||
|
|
// Query params schema
|
||
|
|
export const ProductQuerySchema = z.object({
|
||
|
|
q: z.string().optional(),
|
||
|
|
category: z.enum(ProductCategory).optional(),
|
||
|
|
tags: z.string().optional(), // Comma-separated
|
||
|
|
cursor: z.string().optional(),
|
||
|
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||
|
|
sort: z.string().optional(),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Infer TypeScript types from Zod schemas
|
||
|
|
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
|
||
|
|
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>;
|
||
|
|
export type ProductQuery = z.infer<typeof ProductQuerySchema>;
|
||
|
|
```
|
||
|
|
|
||
|
|
### Schema design rules
|
||
|
|
|
||
|
|
1. **Always `trim()` strings** — prevents " Chicken " vs "Chicken" issues
|
||
|
|
2. **Set reasonable `max()` lengths** — prevents abuse
|
||
|
|
3. **Use `nonnegative()` for nutrition values** — calories can't be negative
|
||
|
|
4. **Use `z.coerce.number()`** for query params — they arrive as strings
|
||
|
|
5. **Always set `.default()` for optional arrays** — prevents `undefined` issues
|
||
|
|
6. **Use `z.enum()`** for TypeScript enums (Zod v4 unified `z.enum` handles both string arrays and TS enums)
|
||
|
|
7. **Use `z.email()`, `z.url()`, `z.uuid()`** as top-level validators (Zod v4 style)
|
||
|
|
|
||
|
|
### Using Zod schemas in Fastify
|
||
|
|
|
||
|
|
The `fastify-type-provider-zod` plugin auto-validates request schemas:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||
|
|
import { CreateProductSchema, type CreateProductInput } from '@meshitrack/shared';
|
||
|
|
|
||
|
|
const app = fastify.withTypeProvider<ZodTypeProvider>();
|
||
|
|
|
||
|
|
app.route({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/api/v1/products',
|
||
|
|
schema: {
|
||
|
|
body: CreateProductSchema,
|
||
|
|
response: { 201: ProductResponseSchema },
|
||
|
|
},
|
||
|
|
handler: async (request, reply) => {
|
||
|
|
// request.body is fully typed as CreateProductInput
|
||
|
|
const product = await service.create(request.householdId, request.body);
|
||
|
|
return reply.status(201).send(product);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
Validation errors are automatically caught by the global error handler.
|
||
|
|
|
||
|
|
### Using Zod schemas in Next.js
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Form validation with react-hook-form
|
||
|
|
import { useForm } from 'react-hook-form';
|
||
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||
|
|
import { CreateProductSchema, type CreateProductInput } from '@meshitrack/shared';
|
||
|
|
|
||
|
|
const form = useForm<CreateProductInput>({
|
||
|
|
resolver: zodResolver(CreateProductSchema),
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Utility Types for API Responses
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// types/common.ts
|
||
|
|
export interface PaginatedResponse<T> {
|
||
|
|
data: T[];
|
||
|
|
pagination: {
|
||
|
|
cursor: string | null;
|
||
|
|
hasMore: boolean;
|
||
|
|
total?: number;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ApiError {
|
||
|
|
statusCode: number;
|
||
|
|
error: string;
|
||
|
|
message: string;
|
||
|
|
details?: Record<string, string[]>;
|
||
|
|
timestamp: string;
|
||
|
|
path: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ApiSuccess<T> {
|
||
|
|
data: T;
|
||
|
|
message?: string;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Null vs Undefined Convention
|
||
|
|
|
||
|
|
- **`undefined`**: field is not provided / not applicable (use in DTO inputs)
|
||
|
|
- **`null`**: field is explicitly empty / cleared (use in database documents)
|
||
|
|
- **In Zod**: use `.optional()` for undefined, `.nullable()` for null, `.nullish()` for both
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Input: optional means "not provided"
|
||
|
|
brand: z.string().optional(); // string | undefined
|
||
|
|
|
||
|
|
// Database: null means "explicitly cleared"
|
||
|
|
brand: z.string().nullable(); // string | null
|
||
|
|
|
||
|
|
// API response: could be either
|
||
|
|
brand: z.string().nullish(); // string | null | undefined
|
||
|
|
```
|
||
|
|
|
||
|
|
## Import/Export Convention
|
||
|
|
|
||
|
|
### Barrel exports in each directory
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// types/index.ts — use .js extensions for ESM
|
||
|
|
export * from './product.js';
|
||
|
|
export * from './recipe.js';
|
||
|
|
export * from './pantry.js';
|
||
|
|
// ...
|
||
|
|
|
||
|
|
// Root index.ts
|
||
|
|
export * from './types/index.js';
|
||
|
|
export * from './enums/index.js';
|
||
|
|
export {} from /* specific schemas */ './validation/index.js';
|
||
|
|
```
|
||
|
|
|
||
|
|
### Use `import type` for types-only
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// When importing only types, use `import type` (required by verbatimModuleSyntax)
|
||
|
|
import type { Product, NutritionInfo } from '@meshitrack/shared';
|
||
|
|
|
||
|
|
// When importing values (enums, schemas, functions), use regular import
|
||
|
|
import { ProductCategory, CreateProductSchema } from '@meshitrack/shared';
|
||
|
|
```
|