- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Getting TypeScript Projects to Compile Cleanly
Globomantics runs a small monorepo: a shared 'core' package, an 'ingest' package that still leans on a legacy JavaScript parser, and a 'dashboard' app built with Vite. The team's builds have grown unpredictable: declaration files land in the wrong place, tsc and Vite disagree about which files matter, watch mode thrashes on CI, a strictness upgrade exposed unsafe assumptions at the ingest package's legacy JavaScript boundary, and nobody is sure which packages actually need rebuilding after a change. You are the engineer asked to write the orchestration logic that makes these decisions explicit and testable.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet the Globomantics build orchestrator
The Globomantics monorepo
Globomantics ships a small TypeScript monorepo with three packages:
- core: shared types and utilities every other package imports.
- ingest: data pipeline code that still calls into a legacy, untyped JavaScript parser.
- dashboard: a Vite-built app that consumes both
coreandingest.
The build has become unpredictable enough that nobody trusts a green CI run. Declaration files sometimes land next to the wrong
outDir,tscand Vite silently disagree about which files were actually checked versus bundled, watch mode burns CPU on CI runners, a recentstrictupgrade exposed unsafe assumptions atingest's legacy JavaScript boundary, and acorechange sometimes fails to trigger adashboardrebuild. ### What you're buildingYou are not editing
tsconfig.jsonfiles by hand in this lab. Instead, you will write the orchestration logic a build tool would run before, during, and after invokingtscand Vite. Across Steps 2 to 5 you will implement five pieces of that logic as plain TypeScript modules with fully testable behavior:- Path mapping: where a source file's
.jsoutput and.d.tsdeclaration should land givenrootDir/outDir, and whether a file even belongs in the build. - Type-check / bundle reconciliation: comparing what
tsctype-checked against what Vite's manifest actually bundled. - Watch strategy and config merging: choosing polling vs native file events, and merging a shared base config into a project's own without losing overrides.
- Legacy JS interop under strict mode: repairing the null-safety gaps that
ingest's legacy parser exposes once strict checks apply. - Project references and incremental rebuilds: using a reference graph and changed-file hashes to decide which packages actually need rebuilding.
Each module lives under
src/and is exercised directly by tests, so you'll see the exact effect of every change without waiting on a realtscor Vite invocation. ### How the steps build on each otherStep 2 starts with the most mechanical piece, path mapping, because every later module assumes you already know how a source file maps to its build output. Step 3 layers the type-check/bundle reconciliation on top. Step 4 tackles watch mode and configuration merging, which depend on knowing how included/excluded files are determined. Step 5 closes with the two hardest problems: repairing the null-safety gaps in code that consumes legacy JavaScript output, and computing rebuild sets across project references. By the end you will have a small but complete orchestration library, each piece independently testable. ### Checking your progress as you work
Each task is graded as soon as you complete it, and you can run the same checks yourself at any time in the Terminal. To run one task's test:
bash runTest.sh "task 2.1"To run the whole suite:
npx --no-install vitest run --reporter=dotEach failure names the task it belongs to, so the output tells you which task to go back to rather than which line to stare at.
This project has a strict
tsconfig.json, and each task's test runs the TypeScript compiler before it checks behavior, so a type error fails the task with the compiler's own message. To check your types on their own, run:npm run typecheckWhen the command returns to the prompt with no errors listed, your types are clean.
-
Challenge
Step 2: Map the source tree to its build outputs
Mapping source files to their outputs
When
tsccompiles withrootDirandoutDirset, it re-parents the source tree: a file atrootDir/widgets/parser.tslands atoutDir/widgets/parser.js, preserving every intermediate directory. Get this mapping wrong in your own tooling and you'll look for build artifacts in the wrong place, or clobber files across packages that happen to share a directory name.src/pathMapper.tsalready has the shared path-normalizing helpers the team relies on elsewhere; you're about to add the first mapping function that uses them. ### Declaration files follow the same shape, with two conditions.d.tsfiles land in the same relative slot as compiled.jsoutput, but the extension swap is different, and TSX source needs its trailingxstripped before.d.tsis appended (Button.tsxemitsButton.d.ts, neverButton.d.tsx). Two compiler options also decide whether and where they land. tsc emits declaration files only whendeclarationistrue, so with it off there is no path to report at all. And whendeclarationDiris set, declarations go under it instead ofoutDir, while the compiled.jsfiles stay inoutDir. Overlooking either one is how a package ends up looking for its types in the wrong place, or in a place that was never written. ### Not every file under rootDir belongs in the buildinclude/excludepatterns in atsconfig.jsondecide which files underrootDirare actually part of the project. A stray.spec.tsfile or a scratch file left in a package directory can otherwise get mapped to an output path nobody expects. Before the orchestrator trusts a source tree, it needs to partition files into those the include patterns cover and those they don't. -
Challenge
Step 3: Reconcile the type-checker with the bundler
Diagnostics don't arrive pre-organized
A no-emit type-check pass produces a flat stream of diagnostics, each tagged with a file, a severity, and a message, but nothing groups them by file or tells you which files are worst affected.
src/diagnostics.tsholds the raw diagnostic shape the simulated checker emits; you'll turn that stream into per-file summaries the rest of the orchestrator can act on. ### Vite's manifest tells a different storyVite's production manifest records every module it actually bundled, including transitively-imported files pulled in through an entry point's
importslist.src/manifest.tsdefines the manifest entry shape; you'll flatten that structure into the plain list of bundled file paths the reconciler needs to compare against whattscchecked. ### Where tsc and Vite disagreeA file can be type-checked but never bundled (dead code the checker still walks), or bundled but never type-checked (a
.jsfile Vite happily pulls in thattscskipped). Both are bugs worth flagging.src/reconciler.tsis where the checker's file list and the bundled-file list fromparseManifestfinally meet.Take the checker's side from its own list of checked files, the way
tsc --listFilesOnlyprints it, and not fromcollectDiagnostics. A clean file has no diagnostics, so it never appears in those summaries, and it would be wrongly reported as bundled but not type-checked. -
Challenge
Step 4: Pick a watch strategy and merge shared configuration
Watch mode isn't one-size-fits-all
Native filesystem events are cheap and instant, but CI containers frequently run on filesystems where those events don't fire reliably, and native watchers can also choke once a project has enough files.
src/watchStrategy.tsholds the environment-signal shape the orchestrator already collects; you'll turn those signals into an actual polling-vs-native decision. ### Shared config, project overridesThe Globomantics packages all
extendsa shared basetsconfig.json, and tsc resolves that in two different ways.compilerOptionsmerge key by key, one level deep: a key the project sets replaces the base's value as a whole, so a project'sliborpathsreplaces the base's instead of combining with it.include,excludeandfilesdo not merge at all: when the project defines one of them, its list replaces the base's, and when it leaves one out, it inherits the base's.One more rule from real
extendsis left out here: relative paths in a base config resolve against the base file's own directory, not the project's. The simplified shape insrc/configMerger.tshas no file locations, so you will implement just the merge rules above. ### From merged config to watch settingsOnce a project's effective
includeandexcludeare known, the watcher needs concrete input: the globs to subscribe to, and the patterns to ignore. They do different jobs.includepicks the files to watch, andexcludefilters files out afterwards, so it belongs in the watcher's ignore option and does not edit theincludestrings.src/watchGlobs.tsis where the merged configuration from the previous task becomes those two lists. -
Challenge
Step 5: Tighten checks on legacy JS and wire up project references
Strict mode and the legacy boundary
strictis a bundle of compiler flags, not a single check. The one that matters here isstrictNullChecks, which makesnullandundefinedtypes of their own, so a value typedstring | nullcannot be used as astringuntil you handle thenullcase. The compiler can only do that for values it has types for. A value typedany, which is what a call into untyped JavaScript can give you, opts out of those checks, and a bad value flows straight through.ingestcalls a legacy JavaScript parser whose records can arrive with a missingsku, anulltags list, or aquantitythat is a numeric string.src/ingestParser.tsdescribes that output with theLegacyRawRecordtype and converts it into a strictly typedParsedRecord. It builds as it stands, because the stub ignores its input and returns a fixed value, but it does not convert anything yet. The types tell you which cases to handle, and turning each one into a safe value is runtime code you write. ### Project references form a dependency graphdashboardreferencesingest, which referencescore, a directed acyclic graph, at least when nobody's made a mistake.src/projectGraph.tsholds the package descriptors with their direct reference lists; you'll compute the full transitive closure each package depends on, and catch the cycle that would otherwise make an incremental build loop forever. ### Deciding what actually needs rebuildingA changed file only matters if its content hash actually differs from last build, and once a package is known to have changed, every package that transitively depends on it, via the graph you just built, needs rebuilding too, even if none of its own files changed.
src/rebuildPlanner.tsties changed-file hashes to the reference graph to produce that final rebuild set. -
Challenge
Step 6: Run and check the application
Every task is done, and each one was graded the moment you completed it. What you have not done yet is watch the whole application run at once, which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and start the dev server:
npm run devIt serves the application on port 5173. Switch to the Web Browser tab to use it: the page is running the functions you implemented in the previous steps, so what you see is your own code rather than a demonstration of it.
The page has one section per module you wrote: path mapping, grouped diagnostics, manifest coverage, the watch strategy and globs, a legacy ingest record, and the project graph with its rebuild plan. Each section started empty or zeroed and now shows real values. The Packages to rebuild line lists the changed package plus every package that depends on it.
The server reloads on save, so you can change a file and see the result without restarting anything. When you are done looking around, press Ctrl+C in the Terminal to stop the server and free the terminal for the checks below. ### Run the final checks
The checks you used along the way work the same now. With all twelve tasks done, run the whole suite and the TypeScript compiler on your finished code in the Terminal:
npx --no-install vitest run --reporter=dot npm run typecheckThe suite reports every test passing. After the two lines npm prints for the script,
typecheckreturns to the prompt with no errors listed, which meansstrictmode found no type errors insrc/ortests/. Two things are worth trying before you finish. First, break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.Second, ask the compiler to catch a type mistake. In
src/pathMapper.ts, addreturn 42;as the first line ofresolveOutputPath's body, then run the compiler again:npm run typecheckThe compiler reports that type
numberis not assignable to typestring, andbash runTest.sh "task 2.1"fails on that error before it checks any output. Remove the line when you are done. Congratulations on completing the Globomantics build orchestrator.You built the orchestration logic behind it: path mapping from source files to compiled and declaration outputs, reconciliation of type-check diagnostics with a bundler manifest, watch strategy and shared-config merging, repairs for null-safety gaps in legacy JavaScript interop, and a project reference graph that decides which packages an incremental build must rebuild. The page you ran in the browser calls every one of those modules.
You also ran TypeScript's strict compiler over all of it and watched it catch a type mistake before any test did. Reasoning about where build output lands, what the type-checker and bundler each see, and what a change really invalidates are skills that carry over to any multi-package TypeScript project.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.