Lesson 23 / 23
Build and Run Flow
Separating type checking from publish output, publish behavior on error, the options enabling per-file transpilation, type-only imports, and type-stripping runtimes.
Contents
Throughout the course, the compiler both did type checking and produced output. Separating these two jobs is a decision that determines a project’s build time and deployment layout.
The reason for the separation was established in the first lesson: types are erased at compile time. Erasure is mechanical and fast; type checking, on the other hand, requires resolving the entire program and is slow. Splitting the two jobs shortens the development loop.
Publishing on Error
The behavior seen in the first lesson is re-examined here, this time in a project
context. src/main.ts:
const value: number = "21.4"; console.log(value);
If the configuration is set to produce output with outDir, compilation gives this
diagnostic:
src/main.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
and still writes the dist/main.js file:
const value = "21.4"; console.log(value); export {};
The noEmitOnError option changes this behavior; the same compilation gives the
diagnostic and the dist directory is never created.
Which is wanted depends on context. During development, running code despite a type error is useful — the error is in one module, the behavior being tried is in another. In the publish pipeline, the opposite holds: code that has not passed checking should not be packaged. Using two separate configurations is common for this reason.
The trailing export {}; line is a separate detail: the compiler adds it to mark the
file as a module. The moduleDetection setting from the Module Resolution lesson
determines this behavior.
Splitting Into Two Steps
The split is set up with two commands:
- Type checking:
tsc --noEmit. Produces no output, only gives diagnostics. - Output production: A transpiler that runs per file. Erases types, adapts syntax to the target, does not do type checking.
The reason the second step is fast is that it can run per file: transpiling one file requires no looking at other files. But this is impossible for some TypeScript notations — the transpilation needs type information to be correct.
The isolatedModules option catches these notations. The following
src/measurement.ts:
export interface Measurement { id: string; value: number; } export function average(records: readonly Measurement[]): number { return records.length === 0 ? 0 : records.reduce((t, r) => t + r.value, 0) / records.length; }
and src/main.ts:
import { Measurement, average } from "./measurement.js"; export { Measurement }; const records: Measurement[] = [{ id: "s-01", value: 21.4 }]; console.log(average(records));
With isolatedModules on:
src/main.ts(3,10): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.
The problem is this: a transpiler running per file cannot know whether the name
Measurement is a type or a value. If it is a type, the export line has to be erased
entirely; if it is a value, it has to be kept. Deciding requires looking at the
measurement.ts file, and that breaks per-file transpilation.
The verbatimModuleSyntax option applies the same rule to imports too:
src/main.ts(1,10): error TS1484: 'Measurement' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled. src/main.ts(3,10): error TS1205: Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.
The fix is using type-only notations:
import type { Measurement } from "./measurement.js"; import { average } from "./measurement.js"; export type { Measurement }; const records: Measurement[] = [{ id: "s-01", value: 21.4 }]; console.log(average(records));
This file compiles without error. The produced dist/main.js:
import { average } from "./measurement.js"; const records = [{ id: "s-01", value: 21.4 }]; console.log(average(records));
The output of node dist/main.js is 21.4.
The import type notation says in the declaration that the line will be erased
entirely. The gain is not only speed: taking only a type from a module whose import
has a side effect does not cause that module to load in the output. Same principle as
tree shaking in the Modules, Tooling and the Ecosystem course — knowing an erasable
dependency is erasable.
Incremental Builds
The incremental option makes the compiler write information left over from its
previous run to a file. On the next build, only changed files and the ones that
depend on them are re-resolved.
With the option on, a file named tsconfig.tsbuildinfo is created at the project
root. This file is a build artifact; it is not put under version control and is
removed in cleanup steps. Its location can be changed with tsBuildInfoFile.
Watch mode (tsc --watch) carries the same idea into a continuously running
process: file changes are watched, and only the affected part is re-checked. During
development, this mode is preferred over a one-shot build.
Type-Stripping Runtimes
Some runtimes can run TypeScript files directly: they strip type annotations and execute the remaining JavaScript. This is the first lesson’s type erasure principle carried over to runtime.
The distinction has to be seen. The following file contains a type error:
const value: number = "21.4"; console.log(value);
In such a runtime, the file runs and prints 21.4. No type checking happens at
all. Stripping is mechanical; it has nothing to do with the guarantee a compiler
gives.
The second limit shows up in TypeScript notations that produce code:
enum Unit { Celsius = "C", } console.log(Unit.Celsius);
This file does not run:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
The Basic Types lesson showed that an enum is the exception to the type erasure principle; its consequence is concrete here. A runtime that only strips cannot run syntax that cannot be erased. The same restriction covers parameter properties too.
For this reason, the erasableSyntaxOnly option is kept on in projects targeting a
type-stripping runtime: notations that cannot be erased are reported at compile time,
instead of turning into a runtime surprise.
Comparison of the three paths:
| Path | Type checking | Speed | Constraint |
|---|---|---|---|
| Compiling with the compiler | Yes | Low | — |
| Separate checking + per-file transpilation | Separate step | High | Requires isolatedModules |
| Type-stripping runtime | No | High | Non-erasable syntax does not run |
What the three have in common is that type checking must not disappear. In the second and third paths, checking has to happen somewhere — in the publish pipeline, in the editor, in a pre-processing hook. When it does not happen, what remains is JavaScript with type annotations written on it.
Summary
- A type error does not by itself stop publishing;
noEmitOnErrorchanges this, and separate configurations for development and publishing are common. - Type checking (
tsc --noEmit) and output production can be split into separate steps; the second step’s speed comes from being able to run per file. isolatedModulesandverbatimModuleSyntaxreport notations that break per-file transpilation; the fix isimport typeandexport typenotation.- The
incrementaloption writes build information to a file; this file is a build artifact and is not put under version control. - Type-stripping runtimes do no type checking at all and cannot run non-erasable
syntax;
erasableSyntaxOnlycatches these notations at compile time.
Course Wrap-Up
The course began with a single question: what can be said about a program without running it? TypeScript’s answer was a layer of proof built on source code that leaves nothing behind once translation is done.
This layer’s limits were drawn from the start and re-tested in every topic. Types are
erased at compile time; they do not change runtime behavior; they do not prove the
shape of the outside world. These constraints are not a shortcoming, they are the
definition of what the layer is — and the non-working output of the paths setting
and the lack of checking in a type-stripping runtime are two different views of the
same constraint.
A single model was carried through the whole course. The measurement record started as a JavaScript object carrying no guarantees; its fields were typed, its unit was bound to a closed set, its valid and invalid states were split apart with a discriminated union, its source was abstracted with an interface, its log was generalized with a type parameter, its derivatives were computed with mapped and conditional types, its boundary was guarded with a type predicate, and finally it was published outward with a package manifest. What was gained at every step was the same thing: moving a problem that would surface at runtime to compile time.
Three principles to take from the course:
Proof is produced at the boundary. Data from a system boundary is received as
unknown, checked at runtime, and the result is recorded with a type predicate. The
as notation is not proof, it is a debt.
The model stays in one place. Derived types are not written by hand, they are computed from the real type; fixed lists come from a single source, both as a value and as a type. The model’s growth forces, through the compiler, a review of the code that uses it.
Strictness is not a preference, it is a scope decision. Every flag corresponds to an error class; leaving a flag off means leaving that class to runtime.
Next comes where this code will run. The Node.js Runtime course covers the server-side runtime: the phases of the event loop, filesystem and network interfaces, streams and backpressure, process management, and logging and monitoring under production conditions. The type layer built in this course holds there too — the interfaces the runtime offers are typed with declaration files, and the module resolution rules are that runtime’s rules.
To keep your progress and take notes, Log in
My notes
Log in to take notes.