EthicsComputerCourses
Join training +91 90532 10052

Legacy TypeScript at Work

TypeScript · Lesson 41 of 42 Advanced 8 min read Updated

Your first TypeScript job will probably not be a fresh project. It will be a codebase started five or ten years ago, written for TypeScript 2, 3 or 4, by people who have since moved on. It may use namespace blocks, /// <reference> comments at the top of files, import x = require(...), decorators with a different meaning from today's, and any on every second line.

None of this is "wrong" code; it was normal practice when it was written. This lesson teaches you to read each pattern, explains what replaced it and why, and shows how to modernise a little at a time without breaking a working product.

Namespaces

Before ES modules existed, TypeScript grouped code with namespaces (originally called "internal modules"). A namespace is a named object that holds exported members; everything not exported stays private inside it. Namespaces with the same name merge, even across files:

TypeScriptRun it yourself
namespace Billing {
  const GST_RATE = 0.18; // not exported: private to the namespace

  export function withGst(amount: number): number {
    return Math.round(amount * (1 + GST_RATE));
  }

  export namespace Format {
    export const rupees = (n: number): string => "₹" + n.toLocaleString("en-IN");
  }
}

namespace Billing {
  export function invoiceLine(item: string, amount: number): string {
    return `${item}: ${Billing.Format.rupees(Billing.withGst(amount))}`;
  }
}

console.log(Billing.invoiceLine("School bag", 1299));
console.log(Billing.Format.rupees(150000));
console.log(typeof Billing, Object.keys(Billing));
Output
School bag: ₹1,533
₹1,50,000
object [ 'withGst', 'Format', 'invoiceLine' ]

The last line shows the key fact: a namespace is a real JavaScript object at run time. tsc compiles it into a function that fills a var Billing object. That makes namespaces not erasable: they cannot just be stripped away, so Node.js type stripping refuses them and erasableSyntaxOnly reports them (see Enums & Erasable Syntax).

Single-file tools also struggle with them. Inside the second Billing block, tsc lets you write Format.rupees(withGst(amount)) without the Billing. prefix, because it sees both blocks together. Babel, which this site's editor uses, compiles that version into code that fails with ReferenceError: Format is not defined. That is why the example above spells out Billing.Format.

In very old code you may also see module Billing { }, the original keyword for the same thing. TypeScript 7 rejects it:

TypeScript
module Fees {
  export const term = 1;
}
Output
app.ts(1,8): error TS1540: A 'namespace' declaration should not be declared using the 'module' keyword. Please use the 'namespace' keyword instead.

Modern replacement: one ES module per namespace. Billing.withGst becomes export function withGst in billing.ts, and callers import { withGst } from "./billing.js". If callers rely on the Billing. prefix, import * as Billing from "./billing.js" keeps the call sites unchanged during the move.

Namespaces are still correct in one place: declaration files that describe global libraries or merge into global names, such as namespace Express in Module Augmentation & Publishing Types.

Triple-slash directives

A comment that starts with three slashes and contains an XML-like tag is a triple-slash directive. It is an instruction to the compiler and must be at the very top of a file:

TypeScript
/// <reference path="./billing.ts" />
/// <reference types="node" />
/// <reference lib="es2024" />
  • reference path told the compiler about another file and, with the old outFile option, the order in which to concatenate files. It was the glue of namespace-based projects. With ES modules, import statements express dependencies instead, and outFile was removed in TypeScript 7.
  • reference types loads an @types package. In application code, list types in tsconfig.json ("types": ["node"]) instead. It is still normal inside .d.ts files, such as Vite's /// <reference types="vite/client" />.
  • reference lib loads a built-in library file, like adding an entry to the lib option for one file. Library authors use it in .d.ts files.

When you find reference path lines in application code, they usually disappear naturally as each file is converted to a module.

import = require and export =

When TypeScript first supported Node.js, it modelled CommonJS with its own syntax:

TypeScript
import fs = require("node:fs");
console.log(typeof fs.readFileSync);

import x = require("...") compiles to const x = require("..."), and export = value compiles to module.exports = value. Like namespaces, they are TypeScript-only syntax that produces code, so they are not erasable. With erasableSyntaxOnly turned on, tsc reports:

Output
app.ts(1,1): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.

You will meet export = mostly when reading .d.ts files of CommonJS packages, where it means "this package exports a single value, not named exports". In your own code, use import fs from "node:fs" or import { readFileSync } from "node:fs"; esModuleInterop (always on in TypeScript 7) makes default imports of CommonJS packages work. Converting a whole CommonJS project to ES modules is a bigger change, covered in Modules & Imports.

Old-style decorators

If a project's tsconfig.json has "experimentalDecorators": true, its decorators follow TypeScript's legacy proposal, not the standard decorators you learnt in Decorators. Angular, NestJS, TypeORM and many dependency-injection libraries were built on them, and often also set "emitDecoratorMetadata": true, which emits type information for those frameworks to read at run time.

The two systems look similar but have different function signatures and rules, so do not mix them, and do not remove experimentalDecorators from such a project: the framework depends on it. Follow the framework's own upgrade guide. For new libraries without such a framework, use standard decorators.

Codebases full of any

The most common legacy problem is not syntax; it is any. Teams that migrated from JavaScript in a hurry often typed everything awkward as any, and any spreads: whatever you read from an any is also any. Here is a typical function and the bug it hides:

TypeScriptRun it yourself
// Legacy version: compiles, but checks nothing
function totalMarksOld(report: any): any {
  return report.subjects.reduce((sum: any, s: any) => sum + s.marks, 0);
}

const fromOldApi = { student: "Kabir", subjects: [{ name: "Maths", marks: "78" }, { name: "Hindi", marks: 91 }] };
console.log(totalMarksOld(fromOldApi));

// Modern version: unknown in, checked, precise type out
type Subject = { name: string; marks: number };

function isSubject(x: unknown): x is Subject {
  return typeof x === "object" && x !== null &&
    "name" in x && typeof x.name === "string" &&
    "marks" in x && typeof x.marks === "number";
}

function totalMarks(report: unknown): number | null {
  if (typeof report !== "object" || report === null || !("subjects" in report)) return null;
  const subjects = report.subjects;
  if (!Array.isArray(subjects) || !subjects.every(isSubject)) return null;
  return subjects.reduce((sum, s) => sum + s.marks, 0);
}

console.log(totalMarks(fromOldApi));
console.log(totalMarks({ subjects: [{ name: "Maths", marks: 78 }, { name: "Hindi", marks: 91 }] }));
Output
07891
null
169

The legacy version returned the string "07891": one API sent marks as the text "78", so 0 + "78" became "078" and adding 91 glued on more digits. any let it all through. The modern version refuses bad data (null) and returns a real number for good data.

A practical plan for an any-heavy codebase:

  1. Stop new any. Turn on the no-explicit-any and no-unsafe-* rules from Linting with typescript-eslint as warnings, so new code does not add more.
  2. Fix the boundaries first. API responses, JSON.parse results and third-party callbacks are where any enters. Change them to unknown plus validation (see Runtime Validation & Type-Safe APIs); much of the any downstream then gets real types automatically.
  3. Type the most-used helpers. A shared formatDate(x: any) used in 300 places gives the best return for one fix.
  4. Replace silencing comments. Turn // @ts-ignore into // @ts-expect-error with a reason, so each one fails loudly when the problem is gone.
  5. Measure. Count any and @ts-ignore occurrences per folder and track the number in each sprint. Small, steady progress beats a "big rewrite" that never finishes.

Common mistakes

  • Rewriting a working module just because it uses namespaces. Modernise when you are already changing that code, with tests around it.
  • Removing experimentalDecorators from an Angular or NestJS project. The framework needs it; follow the framework's upgrade path instead.
  • Replacing any with as SomeType. That keeps the danger and hides it better. Use unknown and real checks.
  • Deleting /// <reference> lines without adding imports. The code they pulled in must now be imported explicitly.
  • Mixing a syntax modernisation with behaviour changes. Keep refactors that should not change behaviour in separate commits.

Key points

  • Namespaces are runtime objects that merge by name; they are not erasable, and ES modules replace them in application code.
  • Triple-slash directives (reference path, types, lib) were how files and types were linked before modules; today they mainly appear in .d.ts files.
  • import x = require() and export = are TypeScript's old CommonJS syntax; prefer standard import and export.
  • experimentalDecorators selects the legacy decorator system that frameworks such as Angular and NestJS still use.
  • Clean up any from the boundaries inward, block new any with lint rules, and track progress steadily.

Exercise

Convert this legacy code into an ES module with no any: namespace Attendance { export function percent(days: any, present: any): any { return (present / days) * 100; } }. The function should accept numbers only, return a number rounded to one decimal place, and return 0 when days is 0. Show how a caller that used Attendance.percent(220, 198) would import it with the fewest changes.

Show solution
TypeScript
// attendance.ts
export function percent(days: number, present: number): number {
  if (days === 0) return 0;
  return Math.round((present / days) * 1000) / 10;
}
TypeScript
// report.ts
import * as Attendance from "./attendance.js";

console.log(Attendance.percent(220, 198)); // 90
console.log(Attendance.percent(0, 0));     // 0

The namespace becomes a module, the any parameters become number, and import * as Attendance keeps every existing Attendance.percent(...) call working unchanged.

Quick Quiz

1. Why are namespaces with values not "erasable" syntax?

2. Where is /// <reference types="..." /> still normal today?

3. A NestJS project has "experimentalDecorators": true. What should you do?

4. What is the best first step in an any-heavy codebase?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call