# 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; export type UpdateProductInput = Partial; // API response (without internal fields) export type ProductResponse = Omit; ``` ### 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 & { [__brand]: B }; export type ProductId = Brand; export type HouseholdId = Brand; export type UserId = Brand; ``` ### 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; export type UpdateProductInput = z.infer; export type ProductQuery = z.infer; ``` ### 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(); 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({ resolver: zodResolver(CreateProductSchema), }); ``` ## Utility Types for API Responses ```typescript // types/common.ts export interface PaginatedResponse { data: T[]; pagination: { cursor: string | null; hasMore: boolean; total?: number; }; } export interface ApiError { statusCode: number; error: string; message: string; details?: Record; timestamp: string; path: string; } export interface ApiSuccess { 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'; ```