MeshiTrack/docs/instructions/typescript-zod.md

10 KiB

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

// 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

// 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

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

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

// 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'>;
// 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

// 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:

// 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:

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

// 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

// 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
// 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

// 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

// 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';