When TypeScript Builds but Production Fails: Understanding Type Safety

If you’ve ever written a TypeScript app that runs flawlessly on your local machine but refuses to deploy on Vercel (or any production server), you’re not alone. One of the most common causes of these failures is weak or missing type definitions. Locally, a relaxed configuration can allow unsafe patterns to slip through. In production, stricter settings — often enforced through tsconfig.json
or ESLint — block the build entirely.
The biggest culprit is the any
type. Declaring a variable as any
tells the compiler, “skip the checks, I’ll handle it.” This makes experimentation easy during development but defeats the whole purpose of TypeScript’s safety guarantees. The moment your project moves to an environment that enforces rules like @typescript-eslint/no-explicit-any
, those unchecked variables trigger errors that stop the build.
The rest of this article walks through why these errors happen, how to avoid them, and how tools like Supabase can help you maintain strong, automatically generated types that stay in sync with your database.
Why Local Builds Pass and CI Fails
Your local dev server is usually forgiving. tsconfig.json
may have loose settings, lint isn’t always blocking, and you’re clicking “run” on a machine that doesn’t mind any
. Vercel and most CI pipelines, on the other hand, run strict: true
, ESLint, and fail the build if a type cannot be trusted.
// ❌ nothing is checked
let product: any = { id: 123, name: "Sneaker" };
product.price.toFixed(); // may explode at runtime
// ✅ compiler guards you
type Product = { id: number; name: string; price: number };
let safeProduct: Product = { id: 123, name: "Sneaker", price: 79.99 };
The first snippet “works” locally but CI will halt on any
when rules like @typescript-eslint/no-explicit-any
are enforced.
Build Types Into Your Workflow
The best way to avoid build-stopping errors is to declare exactly what your data looks like. Instead of falling back on any
, create small, meaningful types that describe the expected structure:
1. Define Explicit Shapes
export type Product = {
id: string;
name: string;
price: number;
};
When the compiler knows price
must be a number, it can stop a bug before runtime. You no longer rely on manual checks; the type system enforces them for you.
2. Generate Types from Supabase
Keeping application types synchronized with your database by hand is tedious and error-prone. Supabase solves this by generating TypeScript definitions directly from your schema:
npx supabase gen types typescript \
--project-id "your-project-id" \
--schema public > src/database.types.ts
This creates a single file containing all table and column definitions, ensuring your code matches the database at all times. Any schema change immediately reflects in the generated types.
3. Why Supabase Joins Are a Game-Changer
A standout feature of Supabase is that you can query related tables directly in a single select
statement. Instead of writing separate queries for products
and product_variants
, you write:
const { data } = await supabase
.from("products")
.select(`
id,
name,
product_variants(
id,
size,
stock
)
`);
The API returns nested JSON:
[
{
"id": "123",
"name": "Sneaker",
"product_variants": [
{ "id": "v1", "size": "M", "stock": 8 },
{ "id": "v2", "size": "L", "stock": 5 }
]
}
]
Why it’s great:
One query – no hand-rolled SQL
Types included – auto-complete for
product_variants
Less boilerplate – React can consume the result immediately
4. Map Raw DB Types to UI-Friendly Types
Database rows often include extra fields or nested structures you don’t need in the interface. Transform them early so your components consume exactly what they expect. This step also lets you define your own types, rename fields, and flatten nested shapes without changing the database schema.
// Raw type straight from Supabase
type SupabaseProduct = {
id: string;
name: string;
product_variants: { id: string; size: "S" | "M" | "L" }[];
};
// Clean UI type
type ProductWithVariants = {
id: string;
name: string;
variants: { id: string; size: "S" | "M" | "L" }[];
};
const fetchProducts = async (): Promise<ProductWithVariants[]> => {
const { data, error } = await supabase
.from("products")
.select("id, name, product_variants(id, size)");
if (error || !data) return [];
return (data as SupabaseProduct[]).map((p) => ({
id: p.id,
name: p.name,
variants: p.product_variants,
}));
};
Think of it like:
Warehouse Manifest (raw) → Storefront Catalog (clean)
5. Extra Safety Habits
// Prefer 'unknown' over 'any'
function parseJSONSafe(str: string): unknown {
return JSON.parse(str);
}
// Literal unions for statuses
const STATUSES = ["pending", "paid", "shipped"] as const;
type Status = typeof STATUSES[number];
// Handle null early
type Profile = { id: string; full_name: string | null };
const displayName = (p: Profile) => p.full_name ?? "Anonymous";
Visualizing the Flow
┌────────────┐ supabase gen types ┌─────────────┐ .map() transform ┌────────────┐
│ Database │ ─────────────────────▶ │ TS Types │ ────────────────────▶ │ React UI │
└────────────┘ └─────────────┘ └────────────┘
Define schema → Generate types → Fetch & join → Clean map → Render safely
Quick Pre-Deploy Checklist
strict: true
intsconfig.json
Regenerate types whenever schema changes
Avoid
any
(useunknown
if you must)Map DB types to clean app types
Handle
null
&undefined
Use literal enums for fixed statuses
Takeaway
Strong typing is not optional in production. Make your shapes explicit, generate types straight from your database, and transform raw data before it reaches your UI. With strict mode on, TypeScript will catch errors before you deploy instead of after. Supabase’s type generator keeps your schema and code in sync so your builds pass locally and on Vercel.
Subscribe to my newsletter
Read articles from Krupa Sawant directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
