When migrating a JavaScript codebase to TypeScript file by file with tsc, why is it usually better to convert leaf modules — the ones that import little or nothing else from the project — before converting the hub modules that most of the codebase imports?
answer
- types flow along the import graph
- depends on what it imports
- weak inference from untyped .js
- one hub touches every consumer
- shared type declarations go first
basics
~20 sBecause a converted file's types are only as good as its dependencies. Converting leaves first means every new TypeScript file rests on already-typed code, so inference produces real types instead of weak ones you must revisit, and each change stays small and revertable.
solid answer
~50 sTypes flow from a module's imports into what it exports, so the order of conversion decides the quality of what you get. When `allowJs` is on, tsc does infer types from JavaScript sources, but weakly — unannotated parameters are `any`, object shapes widen, and nullability is not modelled. A `.ts` file written on top of those imports inherits that weakness and its own exported types come out vague, so you end up revisiting it once its dependencies are converted. Going bottom-up means every file you touch already has typed ground under it, and the errors the checker reports are real rather than artifacts of untyped imports. It also keeps the blast radius small: converting a hub tightens types for every consumer at once, which is a large, hard-to-review, hard-to-revert change. The one thing worth doing early regardless of depth is the shared domain types, since everything else leans on them.
go deeper
Know that TypeScript and JavaScript files can coexist in one compilation, and that converting files that depend on little else is the easy starting point.
Explain the mechanism: a module's inferred types depend on the types of what it imports, and inference from untyped JavaScript is loose, so converting bottom-up gives real types instead of ones propped up by any.
Argue the process side too — small revertable pull requests, tests as the feedback loop, avoiding a hub conversion that re-checks a hundred call sites at once — and name the exception for shared domain type declarations.
Own the sequencing across teams: how the dependency graph maps onto ownership, which vertical slices justify breaking bottom-up order, and how the plan survives if the migration is only ever half finished.
## The mechanism: types flow along the import graph A module's exported types are derived from what it does with the values it imports. If `formatMoney` calls a helper whose parameter and return types the checker only knows loosely, the type it infers for `formatMoney` is loose too. Type quality propagates in the same direction as imports: from dependencies toward dependents. That single fact drives conversion order. Convert a dependency-free leaf and the checker has everything it needs to infer precise types for it. Convert a hub first and it sits on foundations the checker can barely see. ## What the checker actually knows about a .js dependency A common misconception is that importing a JavaScript file gives you `any`. With `allowJs` enabled, tsc does more than that: JavaScript files join the program and the compiler infers types for their exports from the code itself. So a `.ts` importer sees *something* — often a usefully-shaped function or object. But that inference is weak in specific, predictable ways: - Function parameters with no annotation and no JSDoc are `any`, so anything you pass type-checks. - Object literals assembled dynamically widen to broad shapes, or to index-signature-ish types. - `null` and `undefined` in the JavaScript source are not modelled the way an annotation would model them, so optionality is guesswork. A new `.ts` module built on those imports produces exported types that look precise but are propped up by `any` underneath. The compiler will happily accept code that is wrong, and you will not find out until the dependency is converted — at which point the dependent file has to be revisited anyway. ```ts // cart.ts, converted while ./money.js is still JavaScript import { format } from "./money.js"; // format's parameter is any, so this is accepted today // and starts failing the day money.ts declares (n: number) export const label = (item: { price: string }) => format(item.price); ``` ## Blast radius and reviewability The second reason is process, not typing. A hub module is imported by dozens of files. The moment its types become real, every consumer is re-checked against them, and the diff explodes: a hundred call sites suddenly report errors that have nothing to do with the file you set out to convert. That change is hard to review, hard to test in isolation, and hard to revert cleanly if it stalls. Leaf modules produce the opposite shape: a small pull request, a handful of new annotations, a test run, done. A migration that ships every day keeps its momentum; one that lives on a long-running branch usually does not. ## The exception: shared types come first Bottom-up is about *code* files. Shared **type declarations** — the domain model, API response shapes, the config object — are worth writing early even though everything depends on them, because they are pure type-level definitions with no implementation to break, and they immediately raise the quality of every file converted afterwards. In practice a good sequence is: shared types first, then leaves, then work inward toward the hubs. ## Finding the leaves You do not need to eyeball this. A module dependency graph over your source tree ranks files by how many project-internal imports they have and by how many files import them. The files with few outgoing internal imports are the leaves; the ones with many incoming imports are the hubs. Convert in roughly ascending order of internal dependencies, and prefer files that are well covered by tests, since those give you the fastest feedback that a conversion edit changed behaviour. A second heuristic: prefer stable files over churning ones. Converting a file three other people are actively editing means merge pain on top of migration pain. ## When to break the rule If a specific hub is the source of most production defects, or one team owns a vertical slice end-to-end, converting that slice top to bottom can be worth the bigger diff. The order is a default, not a law — what you should be able to defend in an interview is *why* the default exists: type quality flows along imports, and small diffs ship.
- If tsc infers types from JavaScript files anyway, why is converting the dependency first worth anything?Because inference from JavaScript is systematically loose in the places that matter: unannotated parameters are `any`, so wrong arguments type-check; object shapes widen; and optionality is not modelled. A dependent written on top of that gets exported types propped up by `any`. Converting the dependency first makes the dependent's errors real errors rather than artifacts you will re-litigate later.
- What happens if a .ts file imports a .js file and allowJs is off?The import fails to resolve — tsc reports that it cannot find the module, because the JavaScript file is not part of the program at all. During a migration that is the signal to turn `allowJs` on so both extensions compile together, rather than to start writing declaration shims for your own source files.
- How do you keep a long migration from regressing while it is in progress?Gate it in CI. Fail the build if a new `.js` file appears in an already-converted directory, and track a checked-in count of remaining type errors that is only allowed to go down. Both are cheap, and both convert the migration from a good intention into a ratchet that survives sprint pressure and staff turnover.
saying these in an interview costs you the question
- Assumes importing a .js file always yields any
- Converts the biggest shared module first for impact
- Thinks conversion order has no effect on inferred types
- Batches hundreds of files into one migration branch
- Ignores that hub conversion re-checks every consumer