Skip to content

Hands‑On Exercise: Migrate a Small JS Module to TypeScript

We’ll start with a small JavaScript module that processes user data.

It’s intentionally messy—just like real legacy code.


Step 1 — Start With a Plain JavaScript Module

userUtils.js

function normalizeUser(user) {
  return {
    id: user.id,
    name: user.name.trim(),
    email: user.email.toLowerCase()
  }
}

function isValidEmail(email) {
  return email.includes("@")
}

module.exports = {
  normalizeUser,
  isValidEmail
}

This module:

  • has no types
  • accepts anything
  • can easily break at runtime

We’ll migrate it safely.


Step 2 — Enable JS Support in TypeScript

Install TypeScript:

npm install --save-dev typescript @types/node

Initialize:

npx tsc --init

Update tsconfig.json:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "outDir": "dist"
  },
  "include": ["src"]
}

This allows TypeScript to compile JS files without type‑checking them yet.


Step 3 — Add JSDoc Types (No Renaming Yet)

Before converting to .ts, we add types using JSDoc.

This gives us type safety inside JavaScript.

Modify:

userUtils.js

/**
 * @typedef {Object} User
 * @property {string} id
 * @property {string} name
 * @property {string} email
 */

/**
 * @param {User} user
 * @returns {User}
 */
function normalizeUser(user) {
  return {
    id: user.id,
    name: user.name.trim(),
    email: user.email.toLowerCase()
  }
}

/**
 * @param {string} email
 * @returns {boolean}
 */
function isValidEmail(email) {
  return email.includes("@")
}

module.exports = {
  normalizeUser,
  isValidEmail
}

Now TypeScript understands:

  • normalizeUser accepts and returns a User
  • isValidEmail accepts a string
  • invalid usage will be flagged

This is a zero‑friction improvement.


Step 4 — Turn on checkJs to Type‑Check JS Files

Update tsconfig.json:

"checkJs": true

Now TypeScript will catch errors in .js files.

Example error caught

If someone calls:

normalizeUser(123)

TypeScript will warn:

Argument of type 'number' is not assignable to parameter of type 'User'.

We’ve improved safety without renaming anything.


Step 5 — Convert the File to TypeScript

Rename:

userUtils.js → userUtils.ts

The JSDoc types still work, but now we can replace them with real TypeScript types.


Step 6 — Replace JSDoc with TypeScript Types

userUtils.ts

export type User = {
  id: string
  name: string
  email: string
}

export function normalizeUser(user: User): User {
  return {
    id: user.id,
    name: user.name.trim(),
    email: user.email.toLowerCase()
  }
}

export function isValidEmail(email: string): boolean {
  return email.includes("@")
}

We now have:

  • real TypeScript types
  • typed exports
  • no runtime changes
  • full editor support

Step 7 — Update Imports in Other Files

If another JS file imports this module:

const { normalizeUser } = require("./userUtils")

It still works because TypeScript emits CommonJS by default.

If using ES modules:

import { normalizeUser } from "./userUtils.js"

Everything remains compatible.


Step 8 — Compile and Run

Compile:

npx tsc

Run:

node dist/userUtils.js

You’ve migrated a JS module to TS with:

  • zero breaking changes
  • minimal edits
  • no rewrites
  • full type safety

Step 9 — Optional: Increase Strictness Later

Once the project stabilizes, enable:

"strict": true

Or incrementally:

  • noImplicitAny
  • strictNullChecks
  • noUncheckedIndexedAccess

This tightens type safety without overwhelming the migration.


What You Learned

This hands‑on exercise demonstrates a realistic, low‑friction migration path:

1. Start with JS

  • keep everything working
  • no renaming required

2. Add JSDoc types

  • instant type safety
  • no syntax changes

3. Enable checkJs

  • TypeScript validates JS files

4. Rename to .ts

  • minimal code changes
  • types become first‑class

5. Replace JSDoc with TS types

  • full TypeScript benefits
  • no runtime changes

This is exactly how real teams migrate large JavaScript codebases safely and incrementally.