DEV Community

Cover image for Day 44 of #100DaysOfCode — Adding TS and Zod to Auth Program
M Saad Ahmad
M Saad Ahmad

Posted on

Day 44 of #100DaysOfCode — Adding TS and Zod to Auth Program

Back on Day 40, I built a mini authentication system using Node.js, Express, MongoDB, Mongoose, bcrypt, and JWT. It worked fine, but it was pure JavaScript: no type safety, no runtime validation. Today, on Day 44, I decided to refactor the entire thing with TypeScript and Zod.

Link to Day 40


TL;DR

Refactored the Mini Auth System with TypeScript and Zod. TypeScript catches type errors at compile time, Zod validates incoming request data at runtime. The best part: z.infer<> generates TypeScript types directly from Zod schemas, so you never define the same shape twice. Key changes: typed Mongoose models with IUser, extended Express's Request for req.user, and replaced blind req.body access with safeParse().


What Changed at a High Level

The folder structure stayed mostly the same. The key additions were:

  • Renaming all .js files to .ts
  • Moving everything into a src/ folder
  • Adding a schemas/ folder for Zod schemas
  • Updating tsconfig.json and package.json scripts

The updated package.json scripts:

"scripts": {
  "dev": "nodemon --exec ts-node src/server.ts",
  "build": "tsc",
  "start": "node dist/server.js"
}
Enter fullscreen mode Exit fullscreen mode

Packages that Actually Need to Install

Not every package needs a separate @types/* installation. Some bundle their own types:

Package Types
zod ✅ Built-in
mongoose ✅ Built-in (since v5.11)
express @types/express needed
bcrypt @types/bcrypt needed
jsonwebtoken @types/jsonwebtoken needed
node @types/node needed
npm install -D typescript ts-node zod @types/node @types/express @types/bcrypt @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Note: @types/mongoose is outdated and no longer needed — installing it can actually cause type conflicts.


The Model: Typing Mongoose Documents

The original user.js just had a plain schema with no types. In TypeScript, Mongoose doesn't know the shape of your document unless you tell it.

Key change — create a UserInterface interface:

import mongoose, { Document } from "mongoose";

export interface UserInterface extends Document {
  name: string;
  email: string;
  password: string;
}

const userSchema = new mongoose.Schema<UserInterface>({ ... });
const user = mongoose.model<UserInterface>("User", userSchema);

export default user;
Enter fullscreen mode Exit fullscreen mode

Why extends Document? Because it adds Mongoose's built-in document methods like .save() on top of your own fields. The generics on Schema<UserInterface> and model<UserInterface> then tie everything together. So when you call User.findOne(), TypeScript knows exactly what fields come back.


The Middleware: Extending Express's Request Type

This was the trickiest and head-scratching part. When the auth middleware attaches the decoded JWT to req.user, TypeScript throws an error because user doesn't exist on Express's default Request type.

The cleanest fix is to extend Request with a custom interface:

import { Request, Response, NextFunction } from "express";
import jwt, { JwtPayload } from "jsonwebtoken";

export interface AuthRequest extends Request {
  user?: JwtPayload | string;
}

const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];
  // ...
  const decoded = jwt.verify(token, process.env.JWT_SECRET as string);
  req.user = decoded;
  next();
};
Enter fullscreen mode Exit fullscreen mode

Why JwtPayload | string? Because that's exactly what jwt.verify() returns. It depends on how the token was signed.

Why as string on process.env.JWT_SECRET? Because TypeScript types all environment variables as string | undefined. The as string tells it to trust that the value exists.


Zod: One Schema, Two Benefits

This is where Zod really shines. Instead of writing a TypeScript interface AND separate validation logic, you write one Zod schema and get both for free.

src/schemas/auth.schema.ts:

import { z } from "zod";

export const registerSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  password: z.string().min(6)
});

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1)
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
Enter fullscreen mode Exit fullscreen mode

z.infer<> extracts a TypeScript type directly from the schema — so RegisterInput and LoginInput are automatically in sync with your validation rules. No duplication.


The Controllers: Using Zod's safeParse

In the controllers, instead of trusting req.body blindly, we now validate it first with safeParse():

export const register = async (req: Request, res: Response) => {
  const parsed = registerSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ errors: parsed.error.flatten() });
  }

  const { name, email, password } = parsed.data;
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Why safeParse() and not parse()? Because parse() throws an exception on failure, safeParse() returns a result object with an errors field, which gives you cleaner, more explicit error handling without a try/catch just for validation.


Project Folder Structure

auth_system/
│
├── src/
│   ├── config/
│   │   └── db.ts
│   ├── controllers/
│   │   ├── auth.controller.ts
│   │   └── user.controller.ts
│   ├── middleware/
│   │   └── auth.middleware.ts
│   ├── models/
│   │   └── user.ts
│   ├── routes/
│   │   ├── auth.route.ts
│   │   └── user.route.ts
│   ├── schemas/
│   │   └── auth.schema.ts
│   └── server.ts
│
├── dist/
│   ├── config/
│   │   └── db.js
│   ├── controllers/
│   │   ├── auth.controller.js
│   │   └── user.controller.js
│   ├── middleware/
│   │   └── auth.middleware.js
│   ├── models/
│   │   └── user.js
│   ├── routes/
│   │   ├── auth.route.js
│   │   └── user.route.js
│   ├── schemas/
│   │   └── auth.schema.js
│   └── server.js
│
├── .env
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

dist/ is only generated after running npm run build. It mirrors the src/ structure but with compiled .js files.


Key Takeaways

TypeScript gives you compile-time safety. It catches type mismatches before you ever run the code.

Zod gives you runtime safety. It validates actual data coming from the outside world (request bodies, API responses, etc.) that TypeScript can't check.

The real power is how they complement each other: Zod handles the boundary where untrusted data enters your app, and TypeScript enforces correctness everywhere after that. And with z.infer<>, you're not writing the same type definition twice.

That's Day 44 done. The codebase is now strictly typed end to end, and any invalid request body gets caught and rejected before it touches the database.

Thanks for reading. Feel free to share your thoughts!

Top comments (0)