10 KiB
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 ofnull/undefinednoImplicitAny— requires explicit types when inference failsstrictPropertyInitialization— 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
.jsextensions on relative imports (TypeScript resolves.tsfrom.jsin nodenext) - Use
import typefor type-only imports (verbatimModuleSyntax: trueenforces this) - No
require()— useimportexclusively - 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'>;
5. Use branded types for IDs (optional but recommended)
// 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
- Always
trim()strings — prevents " Chicken " vs "Chicken" issues - Set reasonable
max()lengths — prevents abuse - Use
nonnegative()for nutrition values — calories can't be negative - Use
z.coerce.number()for query params — they arrive as strings - Always set
.default()for optional arrays — preventsundefinedissues - Use
z.enum()for TypeScript enums (Zod v4 unifiedz.enumhandles both string arrays and TS enums) - 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';