| name | typescript-zod |
| description | Enforces TypeScript strict mode and Zod validation patterns in carwash-saas. Use this when writing types, interfaces, Zod schemas, form validation, or any TypeScript code. Covers no-any policy, type vs interface, schema conventions, type imports, and common strict-mode pitfalls. |
TypeScript + Zod Patterns
TypeScript configuration
Strict mode is enabled everywhere via @carwash/config:
strict: true (enables strictNullChecks, noImplicitAny, etc.)
noUnusedLocals: true
noUnusedParameters: true
noImplicitReturns: true
moduleResolution: bundler
These are not optional. Every file in every package and app must satisfy them.
No any
any disables type checking entirely. It is never acceptable.
function processData(data: any) { ... }
const result = response.data as any;
function processData(data: unknown) {
if (typeof data !== 'object' || data === null) throw new Error('Invalid data');
}
const result = response.data as TransactionRow[];
When you receive data from Supabase that TypeScript can't infer perfectly, define a local type and cast to it — not to any:
type StaffRow = { id: string; name: string; role: string };
return (data ?? []) as StaffRow[];
type vs interface
- Use
type for all object shapes, unions, and aliases derived from Zod schemas
- Use
interface only when extending HTML element attributes
type StatCardProps = {
label: string;
value: number;
color: "blue" | "emerald" | "violet";
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
loading?: boolean;
}
interface TransactionRow {
id: string;
wash_type: string;
}
Zod schemas
Location
All schemas live in packages/types/src/models.ts. Import from @carwash/types, never from deep paths:
import type { Customer, LogWash } from "@carwash/types";
import { CustomerUpsertSchema, LogWashSchema } from "@carwash/types";
import { CustomerUpsertSchema } from "../../packages/types/src/models";
Naming convention
- Schema:
PascalCase suffixed with Schema → CustomerUpsertSchema
- Inferred type:
PascalCase without suffix → type CustomerUpsert = z.infer<typeof CustomerUpsertSchema>
- Always export both together:
export const CustomerUpsertSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
phone: z
.string()
.regex(/^(\+62|0)[0-9]{8,12}$/, "Enter a valid Indonesian phone number"),
});
export type CustomerUpsert = z.infer<typeof CustomerUpsertSchema>;
Domain models vs form input schemas
Keep them separate — domain models reflect DB rows, form schemas reflect what the user inputs:
export const CustomerSchema = z.object({
id: z.string().uuid(),
merchant_id: z.string().uuid(),
name: z.string().min(1),
phone: z.string().min(1),
created_at: z.string(),
});
export type Customer = z.infer<typeof CustomerSchema>;
export const CustomerUpsertSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
phone: z
.string()
.regex(/^(\+62|0)[0-9]{8,12}$/, "Enter a valid Indonesian phone number"),
});
export type CustomerUpsert = z.infer<typeof CustomerUpsertSchema>;
Enums: always z.enum(), never TypeScript enum
enum WashType {
Basic = "basic",
Premium = "premium",
Detail = "detail",
}
export const WashTypeSchema = z.enum(["basic", "premium", "detail"]);
export type WashType = z.infer<typeof WashTypeSchema>;
Validation on both client and server
- Client (form submit): use
schema.safeParse() before sending to the server
- Server (API route / RPC): re-validate with
schema.parse() — never trust client input
const result = CustomerUpsertSchema.safeParse({ name, phone });
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
setErrors(fieldErrors);
return;
}
const parsed = LogWashSchema.parse(requestBody);
Type imports
Use import type whenever importing only types. This ensures the import is erased at compile time and never included in the bundle:
import type { Database } from "@carwash/types";
import type { Customer, LogWash } from "@carwash/types";
import type { ButtonHTMLAttributes } from "react";
import { Customer } from "@carwash/types";
Mix value and type imports in one statement:
import { CustomerUpsertSchema, type CustomerUpsert } from "@carwash/types";
Handling Supabase return types
database.types.ts contains the full DB schema. Use it for strong typing, but define lean local types for what you actually select:
import type { Database } from "@carwash/types";
type FullProfile = Database["public"]["Tables"]["profiles"]["Row"];
type StaffRow = Pick<
FullProfile,
"id" | "name" | "role" | "qr_token" | "is_active"
>;
Never cast to any to work around Supabase type inference issues. Instead, define the expected shape and cast explicitly:
type TransactionRow = {
id: string;
wash_type: string;
amount_paid: number;
is_free_redemption: boolean;
created_at: string;
customers: { name: string; phone: string } | null;
staff: { name: string } | null;
};
return (data ?? []) as unknown as TransactionRow[];
Strict-mode common pitfalls
noUnusedLocals / noUnusedParameters
function process(data: string, _unused: number) {
return data;
}
function process(data: string, _count: number) {
return data;
}
noImplicitReturns
function getLabel(type: WashType): string {
if (type === "basic") return "Basic";
if (type === "premium") return "Premium";
}
function getLabel(type: WashType): string {
if (type === "basic") return "Basic";
if (type === "premium") return "Premium";
return "Detail";
}
strictNullChecks
const name = supabaseData.customers.name;
const name = supabaseData.customers?.name ?? "—";
Import order
- External dependencies (
react, next/*, @tanstack/*, zod)
- Workspace packages (
@carwash/types, @carwash/lib, @carwash/ui)
- Local app aliases (
@/lib/supabase/client, @/components/Providers)
- Relative imports (
./loyalty, ../utils)
Blank line between each group.
Common anti-patterns to reject
| Anti-pattern | Correct approach |
|---|
any type | Use unknown + narrowing, or an explicit type |
as any cast | as unknown as TargetType if truly necessary |
TypeScript enum | z.enum([...]) + z.infer<> |
interface for a plain data shape | Use type |
Deep path import from packages/types | Import from @carwash/types barrel |
| Value import for type-only use | import type { Foo } |
| Inline anonymous types in function params | Extract to a named type |
| Schema without exported inferred type | Always export type Foo = z.infer<typeof FooSchema> |
| Client-only validation | Validate on both client (safeParse) and server (parse) |
data.field without null check | data?.field ?? fallback |