I used to hate TypeScript.
It felt like I was writing code to satisfy a compiler, not to build a product.
But after scaling multiple Next.js apps to production, I realized something: TypeScript is the only thing standing between you and a 3 AM database corruption.
The problem isn't TypeScript. The problem is that most tutorials teach you how to write types, not how to write applications.
Here is how to stop guessing and make TypeScript work for you.
1
Share Types Between Frontend and Backend
If you have a User interface in your frontend folder and an identical User interface in your backend, you are doing it wrong.
One changes, the other breaks silently, and you ship a bug.
The Fix:
Put core models in a shared package or folder.
export interface User {
id: string;
email: string;
role: "admin" | "user";
}
Now import this everywhere. If you change a role, your entire codebase turns red. That is a feature.
2
Standardize Your API Response Wrappers
"What does this endpoint return?"
If the answer is "I think it's an object...", you've already lost. Force every endpoint to return the exact same shape.
export interface ApiResponse<T> {
data: T;
success: boolean;
error?: string;
}
Usage:
app.get("/users", async (req, res) => {
const users = await db.users.findMany();
const response: ApiResponse<User[]> = {
data: users,
success: true,
};
res.json(response);
});
3
Strictly Type Your Data Fetching
I see this crime committed daily:
const { data } = useQuery(["user"], fetchUser);
You are losing all Type Safety at the finish line. Do this instead:
function useUser(id: string) {
return useQuery<User>({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
}
Now when you type data., your IDE knows exactly what fields exist.
4
Validate Runtime Data with Zod
TypeScript vanishes at runtime. If a user sends a malformed JSON body, TypeScript won't save you. Zod will.
I use Zod at every API boundary.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
type UserInput = z.infer<typeof UserSchema>;
app.post("/users", (req, res) => {
const body = UserSchema.parse(req.body);
saveUser(body);
});
This is the sweet spot: Runtime safety (Zod) + Compile-time safety (TypeScript).
5
Type Your Environment Variables
Environment variables are one of the most common sources of silent production failures. You reference process.env.SUPABASE_URL everywhere, it's undefined in one environment, and your app crashes with a confusing error at runtime instead of at startup.
Fix it once with a validated env module:
import { z } from "zod";
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
RESEND_API_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1),
});
export const env = envSchema.parse(process.env);
Now import env instead of process.env everywhere. If a variable is missing, your build fails fast with a clear error. No more debugging Cannot read property of undefined from a missing env var at 2 AM.
When to Break the Rules
TypeScript discipline has limits. Here are three cases where any or unknown is the right call — and how to contain it:
1. Third-party libraries with bad types
Some packages ship with outdated or missing type definitions. Rather than fighting incomplete @types/* packages throughout your codebase, isolate the mess:
const sdk = require("untyped-legacy-package") as any;
export const doThing = (id: string): Promise<MyKnownType> => sdk.doThing(id);
One controlled escape hatch. The rest of your codebase stays typed.
2. JSON parsing from untrusted sources
JSON.parse returns any. That's correct — you genuinely don't know what you'll get. This is exactly what Zod is for. Parse with unknown, validate with a schema, then proceed with a proper type.
3. Incremental migration from JavaScript
If you're migrating an existing JavaScript codebase, use allowJs: true and checkJs: false, and migrate file by file. Trying to type everything at once leads to shortcuts that make the types meaningless.
A Note on Monorepos
If you're running a Next.js frontend plus a Node.js API in the same repo, the shared types approach from Step 1 scales naturally into a packages/types workspace:
apps/
web/ ← Next.js app
api/ ← Express/Hono API
packages/
types/ ← Shared TypeScript interfaces
src/
user.ts
api.ts
Both web and api import from @yourproject/types. One source of truth. One place to update when the data model changes. This is the structure we recommend and use in our own projects.
The Checklist
Good TypeScript isn't about clever generics. It's about consistent discipline across the whole stack.
Share types for your data models (DRY) Wrap every API response in a standard ApiResponse<T> Type your React Hooks (useUser vs useQuery) Validate all runtime input with Zod Validate environment variables at startup, not at runtime Isolate any any usage behind typed wrappers
Need help building a type-safe full-stack application? At NeoWhisper, we build production-ready apps with TypeScript, Next.js, and modern best practices. Check out our services or contact us.