Skip to content

Instantly share code, notes, and snippets.

@ReaganS94
Created October 20, 2025 14:58
Show Gist options
  • Select an option

  • Save ReaganS94/5a84c43f93a3392a7a62e42ab8a2aa82 to your computer and use it in GitHub Desktop.

Select an option

Save ReaganS94/5a84c43f93a3392a7a62e42ab8a2aa82 to your computer and use it in GitHub Desktop.

Refactoring the MVC Node + Express project to TypeScript

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.

What you start with

.
├── controllers
│   └── student_controller.js
├── dbinit.js
├── package-lock.json
├── package.json
├── routes
│   └── student_route.js
├── schemas
│   └── Student.js
└── server.js

What you will end with

.
├── 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 src folder, you can. The steps stay the same; just adjust rootDir and outDir in tsconfig.json.


Step 1. Install TypeScript and type packages

npm i -D typescript tsx @types/node @types/express @types/cors @types/colors

Why these:

  • typescript is the compiler.

  • tsx runs TypeScript directly in dev without extra config.

  • @types/* gives you editor and compile-time types for Node, Express, CORS, and colors.


Step 2. Add tsconfig.json

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. esModuleInterop lets you use nice default imports like import express from "express".

  • strict: true is recommended. You can set it to false if you hit friction.


Step 3. Update package.json scripts

Update these:

{
  "type": "commonjs",
  "main": "dist/server.js",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}
  • dev uses tsx to run TypeScript directly with watch.

  • build compiles to dist.

  • start runs the compiled JS.


Step 4. Move and rename files

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


Step 5. Convert each file to TypeScript

5.1 src/server.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 esModuleInterop enabled.

  • Port parsed as number.

  • Route and middleware code stays the same.

5.2 src/dbinit.ts

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;

5.3 src/schemas/Student.ts

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 IStudent for strong typing in controllers.

  • Model<IStudent> ensures Student.create and Student.find know the shape.

5.4 src/controllers/student_controller.ts

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> types req.body for the POST route while keeping params and query generic.

5.5 src/routes/student_route.ts

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;

Step 6. Optional, but helpful: type your environment

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.


Step 7. Run it

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.


Step 8. Common pitfalls and quick fixes

  1. "Cannot find module 'express' or its corresponding type declarations"

    • Install @types/express as a dev dependency. You already did in Step 1.
  2. "Property 'bgGreen' does not exist on type 'string'"

    • Ensure you installed @types/colors and that import "colors"; is present near the top of server.ts.
  3. Mixed default and named exports

    • In TS we used export default for the Mongoose model and named exports for controllers. Keep usage consistent in imports.
  4. ESM vs CommonJS confusion

    • We set module to commonjs and esModuleInterop to true. This allows import express from "express"and keeps runtime behavior aligned with your previous setup.
  5. Mongoose model types feel heavy

    • You can start with a plain schema and add the IStudent interface later. The provided version already compiles with strong enough types for controllers.

Done criteria

  • App starts with npm run dev using tsx.

  • All source files are TypeScript under src/ and compile to dist/ with npm run build.

  • Controllers, routes, and schema are typed minimally and readably.

  • No changes to business logic. Only the language and imports changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment