This guide converts your current Node + Express MVC starter from JavaScript to TypeScript with the fewest moving parts. It keeps CommonJS semantics, keeps your folder layout, and adds types where they help most.
.
├── controllers
│ └── student_controller.js
├── dbinit.js
├── package-lock.json
├── package.json
├── routes
│ └── student_route.js
├── schemas
│ └── Student.js
└── server.js
.
├── src
│ ├── controllers
│ │ └── student_controller.ts
│ ├── routes
│ │ └── student_route.ts
│ ├── schemas
│ │ └── Student.ts
│ ├── dbinit.ts
│ └── server.ts
├── dist ← built JS output (generated)
├── package.json
├── tsconfig.json
└── .env ← unchanged, but required at runtime
If you prefer to keep files in place without a
srcfolder, you can. The steps stay the same; just adjustrootDirandoutDirintsconfig.json.
npm i -D typescript tsx @types/node @types/express @types/cors @types/colors
Why these:
-
typescriptis the compiler. -
tsxruns TypeScript directly in dev without extra config. -
@types/*gives you editor and compile-time types for Node, Express, CORS, and colors.
Create tsconfig.json at the project root.
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src/**/*"]
}Notes:
-
We keep CommonJS to match our current project.
esModuleInteroplets you use nice default imports likeimport express from "express". -
strict: trueis recommended. You can set it tofalseif you hit friction.
Update these:
{
"type": "commonjs",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}-
devusestsxto run TypeScript directly with watch. -
buildcompiles todist. -
startruns the compiled JS.
Create src/ and move the JS files, renaming them to .ts:
-
server.js→src/server.ts -
dbinit.js→src/dbinit.ts -
schemas/Student.js→src/schemas/Student.ts -
controllers/student_controller.js→src/controllers/student_controller.ts -
routes/student_route.js→src/routes/student_route.ts
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import "colors"; // type augmentation for colored strings
import connectDB from "./dbinit";
import student from "./routes/student_route";
dotenv.config();
const app = express();
const port = Number(process.env.PORT) || 5000;
connectDB();
// middlewares
app.use(express.json());
app.use(cors());
// Application-level middleware
app.use((req, res, next) => {
console.log("I am an application level middleware");
next();
});
app.get("/", (req, res) => {
res.send("Welcome to the base URL");
});
app.use("/api/students", student);
// catch all other possible paths
app.use((req, res) => {
res.status(404).send("Uh-oh, wrong location");
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`.bgGreen);
});Changes:
-
Import style switched to ESM with
esModuleInteropenabled. -
Port parsed as
number. -
Route and middleware code stays the same.
import mongoose from "mongoose";
const connectDB = async (): Promise<void> => {
try {
const uri = process.env.MONGO_URI as string;
if (!uri) throw new Error("MONGO_URI missing in environment");
const conn = await mongoose.connect(uri);
console.log(
`MongoDB successfully connected: ${conn.connection.name}`.underline.cyan
);
} catch (error) {
console.error({ "OH NO": error });
}
};
export default connectDB;import mongoose, { Schema, Document, Model } from "mongoose";
export interface IStudent extends Document {
first_name: string;
last_name: string;
email: string;
age: number;
createdAt?: Date;
updatedAt?: Date;
}
const StudentSchema: Schema<IStudent> = new Schema(
{
first_name: {
type: String,
required: true,
minLength: [2, "min length is 2 chars"],
maxLength: 50,
},
last_name: {
type: String,
required: true,
minLength: [2, "min length is 2 chars"],
maxLength: 50,
},
email: {
type: String,
required: true,
match: [
/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/,
"Please use a valid email",
],
},
age: {
type: Number,
required: true,
},
},
{ timestamps: true }
);
const Student: Model<IStudent> = mongoose.model<IStudent>(
"Student",
StudentSchema
);
export default Student;Notes:
-
We declare
IStudentfor strong typing in controllers. -
Model<IStudent>ensuresStudent.createandStudent.findknow the shape.
import { Request, Response } from "express";
import Student, { IStudent } from "../schemas/Student";
// get all students
export const getAllStudents = async (req: Request, res: Response) => {
try {
const students = await Student.find();
if (!students.length) {
return res.status(200).json({ msg: "No students in the DB" });
}
return res.status(200).json({ students });
} catch (error) {
return res.status(500).json(error);
}
};
// get one student
export const getOneStudent = async (req: Request, res: Response) => {
try {
const { id } = req.params as { id: string };
const student = await Student.findById(id);
if (student) {
return res.status(200).json(student);
}
return res.status(404).json({ msg: "Student not found" });
} catch (error) {
return res.status(500).json(error);
}
};
// create student
type CreateStudentBody = Pick<IStudent, "first_name" | "last_name" | "email" | "age">;
export const createStudent = async (req: Request<{}, {}, CreateStudentBody>, res: Response) => {
try {
const { first_name, last_name, email, age } = req.body;
if (!first_name || !last_name || !email || age == null) {
return res.status(400).json({ msg: "please provide all fields" });
}
const student = await Student.create({
first_name,
last_name,
email,
age,
});
return res.status(201).json(student);
} catch (error) {
return res.status(500).json(error);
}
};Notes:
Request<{}, {}, CreateStudentBody>typesreq.bodyfor the POST route while keeping params and query generic.
import { Router } from "express";
import { getAllStudents, getOneStudent, createStudent } from "../controllers/student_controller";
const api = Router();
api.use((req, res, next) => {
console.log("I only execute on the STUDENT route");
next();
});
api.route("/")
.get(getAllStudents)
.post(createStudent);
api.route("/:id").get(getOneStudent);
export default api;Create src/types/env.d.ts to describe required environment variables.
declare namespace NodeJS {
interface ProcessEnv {
PORT?: string;
MONGO_URI?: string;
}
}Then include it by adding the types folder to the include array or by placing it under src/ which we already include.
Development:
npm run dev
Build then run:
npm run build
npm start
You should see the "MongoDB successfully connected" message and the server listening log line.
-
"Cannot find module 'express' or its corresponding type declarations"
- Install
@types/expressas a dev dependency. You already did in Step 1.
- Install
-
"Property 'bgGreen' does not exist on type 'string'"
- Ensure you installed
@types/colorsand thatimport "colors";is present near the top ofserver.ts.
- Ensure you installed
-
Mixed default and named exports
- In TS we used
export defaultfor the Mongoose model and named exports for controllers. Keep usage consistent in imports.
- In TS we used
-
ESM vs CommonJS confusion
- We set
moduletocommonjsandesModuleInteroptotrue. This allowsimport express from "express"and keeps runtime behavior aligned with your previous setup.
- We set
-
Mongoose model types feel heavy
- You can start with a plain schema and add the
IStudentinterface later. The provided version already compiles with strong enough types for controllers.
- You can start with a plain schema and add the
-
App starts with
npm run devusingtsx. -
All source files are TypeScript under
src/and compile todist/withnpm run build. -
Controllers, routes, and schema are typed minimally and readably.
-
No changes to business logic. Only the language and imports changed.