Interop with Untyped Code
Migrating incrementally, containing any at a boundary, typing a legacy module from the outside, and measuring progress so a migration actually finishes.
Migrating incrementally, containing any at a boundary, typing a legacy module from the outside, and measuring progress so a migration actually finishes.
The migration has been running for fourteen months. Roughly half the files are TypeScript, the strict flags are off because too much fails, and nobody can say whether it is closer to finished than it was in March. Meanwhile every new file imports something untyped, so the checking that exists is partly illusory.
Most real adoption happens like this — inside a codebase that already works, alongside people shipping features. By the end of this lesson you will know how to contain untyped code rather than let it spread, and how to measure a migration so it converges.
They need different responses.
JavaScript files in your own project. You control them, they can be converted, and the goal is to convert them.
Dependencies with no types. You do not control them, they
will not be converted, and the goal is to describe the parts you
use — the practice course covered declare module for exactly
this.
The first is a migration. The second is a boundary, permanently.
{
"compilerOptions": {
"allowJs": true,
"checkJs": false
}
}allowJs lets TypeScript files import JavaScript ones, so the
checker knows something about them from inference. checkJs
turns on checking of the JavaScript itself, which on a large
codebase is thousands of errors at once.
Enable it per file instead:
// @ts-check
export function makeCaption(filename) { ... }That one comment gives a file real checking, and JSDoc supplies the types it cannot infer:
/**
* @param {string} filename
* @param {{ separator?: string }} [options]
* @returns {string}
*/
export function makeCaption(filename, options = {}) { ... }JSDoc types are checked exactly like annotations — the same type system, different syntax. For a file that must stay JavaScript, this is a genuine option rather than a consolation.
Bad — importing untyped code directly.
import { processOrder } from "./legacy/orders.js";
const result = processOrder(order);
const total = result.total * 1.2;
saveInvoice({ amount: total, customer: result.customer.id });Good — one typed module in front of it.
// src/legacy/orders.ts
import { processOrder as raw } from "./orders.js";
export type ProcessedOrder = {
total: number;
customer: { id: string };
};
export function processOrder(order: Order): ProcessedOrder {
return OrderResultSchema.parse(raw(order));
}In the first version result is any, so result.total,
everything computed from it, and everything passed onward are
all unchecked — a typo in customer.id compiles, and the
contamination reaches modules that never touched the legacy
code. That is the spreading any from the practice course, and
an untyped dependency is its most common source.
The wrapper is the only file that touches the untyped module. The rest of the codebase imports a typed function, and — because this is a runtime boundary — the schema actually verifies the shape rather than asserting it.
The general rule: an untyped module gets exactly one typed neighbour, and nothing else imports it. A lint rule can enforce that, using the layering technique from the practice course.
An order that keeps the build green throughout.
Convert leaves first
A file with no local imports has nothing to fight. Working inward means every file you convert already has typed dependencies.
Rename, then fix
.js to .ts produces a list of errors, and that list is
the actual work. Resist adding types while renaming — two
tasks, two very different review sizes.
Reach for unknown before any
Where a type is genuinely not known yet, unknown forces a
check at the point of use instead of spreading.
Leave a marked suppression
@ts-expect-error with a ticket number, never a silent one.
That last one, in full:
@ts-expect-error fails when there is nothing to suppress, so
it cannot outlive the problem — unlike @ts-ignore, which
stays forever.
Never @ts-nocheck. It turns off checking for a whole file,
and the file then looks converted while being entirely
unchecked. Every migration that stalls has some of these.
A migration without a number does not finish, because nobody can tell whether a given week helped.
Print those in CI on every build. Four numbers, three of which should go down, and a trend that is visible without anyone running an audit.
Two rules that make the trend hold:
No new JavaScript files. A lint rule or a CI check, so the denominator stops growing.
Convert what you touch. A feature in an untyped file converts it first. That distributes the work across the people already reading that code, which is far more effective than a migration team working through a list.
type-coverage gives a single percentage, which is useful for a
target — and the raw counts are better for seeing which category
is stuck.
The strict-mode adoption from the practice course applies, and combines usefully here:
Both in CI. The strict list only grows, a converted module joins it, and a new module starts on it. That gives you a second number — how much of the codebase is strictly checked — which is the one that actually corresponds to safety.
That closes Advanced TypeScript, and the catalog with it.
You started this track unable to write the language and now know its type system as a programmable thing: conditional and mapped types, template literals, inference and variance, declaration files, library authorship, the compiler API, and what all of it costs.
The question running through this course is the one worth
keeping. What does this cost, and who pays it? A recursive
conditional type costs everyone who reads the error. A
(...args: any[]) => any wrapper costs every call site its
checking. A metaclass-equivalent — a decorator hiding behaviour
— costs whoever has to find it. A brand without a checked
constructor costs you false confidence. None of those are
reasons not to; they are the second half of a decision usually
made with only the first half in view.
The other thread is narrower and more practical: the checker
finishes before your program starts. Everything about
validation at boundaries, branded constructors, schemas over
assertions, and unknown over any follows from that one fact.
Before you go, pick the most elaborate type in a codebase you work on and try to state what it costs and who pays. If the answer comes easily, it was probably a good decision. If it takes a while, that is worth knowing too — and it is the most useful thing this course can leave you with.
TWO KINDS
your JavaScript convertible - a migration
untyped dependencies not convertible - a permanent boundary
SEEING JAVASCRIPT
allowJs: true TS can import JS
checkJs: true checks it - thousands of errors at once
// @ts-check per file instead
JSDoc @param/@returns are checked like annotations
CONTAINING IT
ONE typed module in front of each untyped one
nothing else imports the untyped module - enforce with a
lint rule
validate at that boundary; it is a runtime boundary
otherwise `any` spreads into modules that never touched it
MIGRATING
leaves first - files with no local imports
rename, THEN type - two tasks, two review sizes
unknown before any - it forces a check instead of spreading
// @ts-expect-error TODO(TICKET) - expires when fixed
never @ts-nocheck - the file looks converted and is not
MEASURING
js count, ts count, suppressions, `any` count - in CI
three of them should go down
no new .js files, enforced
convert what you touch, so the work follows the features
a file count alone is misleading: renaming with implicit any
reaches 100% TypeScript and checks nothing
STRICTNESS
tsconfig.strict.json with a growing include list
both configs in CI
"how much is strictly checked" is the number that means safety// @ts-expect-error TODO(DEF-812): types after the orders refactor
const legacy = untypedThing();echo "js: $(find src -name '*.js' | wc -l)"
echo "ts: $(find src -name '*.ts' | wc -l)"
echo "supp: $(grep -rc '@ts-expect-error' src | \
awk -F: '{s+=$2} END {print s}')"
echo "any: $(grep -rc ': any' src | awk -F: '{s+=$2} END {print s}')"// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/photos/**/*", "src/uploads/**/*"]
}tsc --noEmit && tsc --noEmit -p tsconfig.strict.json