Legacy TypeScript at Work
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:
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));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:
module Fees {
export const term = 1;
}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:
/// <reference path="./billing.ts" />
/// <reference types="node" />
/// <reference lib="es2024" />reference pathtold the compiler about another file and, with the oldoutFileoption, the order in which to concatenate files. It was the glue of namespace-based projects. With ES modules,importstatements express dependencies instead, andoutFilewas removed in TypeScript 7.reference typesloads an@typespackage. In application code, list types intsconfig.json("types": ["node"]) instead. It is still normal inside.d.tsfiles, such as Vite's/// <reference types="vite/client" />.reference libloads a built-in library file, like adding an entry to theliboption for one file. Library authors use it in.d.tsfiles.
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:
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:
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:
// 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 }] }));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:
- Stop new
any. Turn on theno-explicit-anyandno-unsafe-*rules from Linting with typescript-eslint as warnings, so new code does not add more. - Fix the boundaries first. API responses,
JSON.parseresults and third-party callbacks are whereanyenters. Change them tounknownplus validation (see Runtime Validation & Type-Safe APIs); much of theanydownstream then gets real types automatically. - Type the most-used helpers. A shared
formatDate(x: any)used in 300 places gives the best return for one fix. - Replace silencing comments. Turn
// @ts-ignoreinto// @ts-expect-errorwith a reason, so each one fails loudly when the problem is gone. - Measure. Count
anyand@ts-ignoreoccurrences 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
experimentalDecoratorsfrom an Angular or NestJS project. The framework needs it; follow the framework's upgrade path instead. - Replacing
anywithas SomeType. That keeps the danger and hides it better. Useunknownand 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.tsfiles. import x = require()andexport =are TypeScript's old CommonJS syntax; prefer standardimportandexport.experimentalDecoratorsselects the legacy decorator system that frameworks such as Angular and NestJS still use.- Clean up
anyfrom the boundaries inward, block newanywith 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
// attendance.ts
export function percent(days: number, present: number): number {
if (days === 0) return 0;
return Math.round((present / days) * 1000) / 10;
}// report.ts
import * as Attendance from "./attendance.js";
console.log(Attendance.percent(220, 198)); // 90
console.log(Attendance.percent(0, 0)); // 0The 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?
A namespace produces runtime code, unlike a type annotation.
2. Where is /// <reference types="..." /> still normal today?
In application code, the types option in tsconfig.json is preferred.
3. A NestJS project has "experimentalDecorators": true. What should you do?
Frameworks built on legacy decorators need that option until they migrate.
4. What is the best first step in an any-heavy codebase?
Fixing boundaries removes any at its source.
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
