TypeScript Compiling

TypeScript code never runs as-is. Before it can execute in a browser or in Node.js, it has to be compiled (more precisely, transpiled) into plain JavaScript. This job belongs to the TypeScript compiler, tsc, which reads your .ts files, checks every type against the rules you’ve written, and then strips those types away to produce ordinary .js files. Understanding exactly what happens during this step — what gets checked, what gets emitted, and what settings control it — is essential for using TypeScript productively on any real project.

Overview / How Compiling Works

When you run tsc on a TypeScript file, the compiler goes through several internal phases. First it parses your source into an abstract syntax tree (AST), the same kind of structure a JavaScript engine builds internally. Next it runs the type checker over that tree: it resolves every variable, function, and expression to a type (either one you wrote explicitly or one it worked out through inference), and it verifies that every assignment, function call, and operation is compatible with those types. If anything is inconsistent — passing a string where a number is expected, accessing a property that doesn’t exist on an object’s type, and so on — the compiler reports a type error with a file name, line, and column.

Crucially, TypeScript’s type system is structural, not nominal: two types are considered compatible if their shapes match, regardless of what they’re named. This matters at compile time because it changes what counts as an error, but it has no effect on emitted code — structural checks are purely a compile-time concept.

After type checking, the compiler performs emit: it walks the same AST again, this time producing JavaScript. During emit, every type annotation, interface, generic parameter, and type-only import is deleted. This is called type erasure. TypeScript’s type system exists only to catch mistakes while you write code; it has no runtime representation at all. The one exception most beginners trip over is enum, which (unlike interfaces or type aliases) does generate real runtime JavaScript, because an enum is partly a value, not purely a type.

By default, running tsc with no arguments looks for a tsconfig.json file in the current directory and compiles the project it describes. Running tsc app.ts compiles a single file directly, ignoring most project-wide configuration. Adding the --watch flag (or -w) puts the compiler into watch mode, where it recompiles automatically every time a source file changes — extremely useful during development.

Syntax

Most real projects are configured through a tsconfig.json file at the project root rather than passing dozens of flags on the command line. Its compilerOptions object controls how compiling behaves:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}
Option Purpose
target Which JavaScript version to emit (e.g. ES5, ES2020). Controls how newer syntax like classes or optional chaining is downleveled.
module Which module system the output uses (commonjs, esnext, nodenext, etc).
outDir / rootDir Where compiled .js files are written, and which folder is treated as the source root.
strict Turns on the full set of strict type-checking flags (noImplicitAny, strictNullChecks, and others) at once.
noEmitOnError If true, stops tsc from writing any JavaScript when there are type errors.
sourceMap Emits .js.map files so debuggers can step through the original .ts source.

With a config file in place, you simply run tsc from the project root, or tsc --watch to keep it running during development. Once your project builds, the resulting files in outDir are plain JavaScript and can be run directly with node — Node itself never sees TypeScript at all.

Examples

Example 1: A basic file, from source to output

interface Person {
  name: string;
  age: number;
}

function greet(person: Person): string {
  return `Hello, ${person.name}! You are ${person.age} years old.`;
}

const user: Person = { name: "Ava", age: 29 };
console.log(greet(user));

Output:

Hello, Ava! You are 29 years old.

The interface and every type annotation here (: Person, : string) exist purely to let the compiler verify that user has a name and age before greet is ever called. None of that appears in the emitted JavaScript — see the “Under the hood” section below for exactly what tsc produces from this file.

Example 2: Enums are the exception to erasure

enum Direction {
  Up,
  Down,
  Left,
  Right,
}

function move(direction: Direction): string {
  return `Moving ${Direction[direction]}`;
}

console.log(move(Direction.Up));
console.log(move(Direction.Left));

Output:

Moving Up
Moving Left

Unlike an interface, an enum compiles into a real JavaScript object at runtime (mapping Up to 0, Down to 1, and so on, plus a reverse mapping from number back to name). That’s why Direction[direction] works: it’s looking up a property on an object that actually exists after compiling, not on a type that vanished.

Example 3: A more realistic program

interface Product {
  id: number;
  name: string;
  price: number;
}

function findCheapest(products: Product[]): Product | undefined {
  if (products.length === 0) return undefined;
  return products.reduce((cheapest, current) =>
    current.price < cheapest.price ? current : cheapest
  );
}

const products: Product[] = [
  { id: 1, name: "Keyboard", price: 49.99 },
  { id: 2, name: "Mouse", price: 19.99 },
  { id: 3, name: "Monitor", price: 199.99 },
];

const cheapest = findCheapest(products);
if (cheapest) {
  console.log(`Cheapest item: ${cheapest.name} at $${cheapest.price}`);
} else {
  console.log("No products available");
}

Output:

Cheapest item: Mouse at $19.99

Because findCheapest is typed to return Product | undefined, the compiler forces you to handle the "empty array" case before using cheapest.name. Under strictNullChecks, writing console.log(cheapest.name) directly (without the if check) would fail to compile — that safety check exists only until compile time, but it prevents a real runtime crash.

Under the Hood: What Gets Emitted

Compiling Example 1 above with a target of ES2020 and CommonJS modules produces JavaScript that looks like this:

"use strict";
function greet(person) {
    return `Hello, ${person.name}! You are ${person.age} years old.`;
}
const user = { name: "Ava", age: 29 };
console.log(greet(user));

Output:

Hello, Ava! You are 29 years old.

Notice what's gone: the interface Person declaration, the : Person parameter annotation, and the : string return annotation. Nothing in the output could tell you this file was ever written in TypeScript — that's type erasure in action. This is also why you cannot check a TypeScript type at runtime (there's no typeof person === "Person"); if you need a runtime check, you need actual JavaScript logic (validating fields, or a library like a schema validator), because by the time the code runs, the type system is gone.

The compiler also performs downleveling based on target: syntax newer than your target (like optional chaining, class fields, or async/await) gets rewritten into older equivalents so it runs on older engines. Setting target too low for the runtime you actually use is harmless for correctness but produces larger, less readable output; setting it too high can produce code that crashes on older engines, since no down-compilation happens for syntax at or below the target.

Common Mistakes

Mistake 1: Assuming a type error stops the build

By default, noEmitOnError is false, so tsc will report type errors and still write JavaScript output unless you tell it not to. Passing the wrong argument type still produces a compile error:

function add(a: number, b: number): number {
  return a + b;
}

const total = add(5, "10");
console.log(total);

Running this through tsc --strict reports: Argument of type 'string' is not assignable to parameter of type 'number'. Yet without noEmitOnError, a .js file is still written and could accidentally get deployed. The fix is both to correct the code and to set "noEmitOnError": true in tsconfig.json so broken builds can never ship silently:

function add(a: number, b: number): number {
  return a + b;
}

const total = add(5, 10);
console.log(total);

Output:

15

Mistake 2: Skipping strict mode and losing type safety

Without strict (specifically noImplicitAny) enabled, parameters with no annotation and no inferable type silently become any, which defeats most of the point of using TypeScript:

function multiply(x, y) {
  return x * y;
}

console.log(multiply(3, 4));

Under --strict, this fails to compile with Parameter 'x' implicitly has an 'any' type. (and the same for y) — the compiler is telling you it can't verify anything about how this function is used. Adding explicit annotations restores real checking:

function multiply(x: number, y: number): number {
  return x * y;
}

console.log(multiply(3, 4));

Output:

12

Best Practices

  • Always keep a tsconfig.json at the project root instead of relying on default single-file compiling — it keeps settings consistent across every file and every teammate.
  • Turn on "strict": true from day one on new projects. Retrofitting strict mode onto a large untyped codebase later is far more painful than starting with it.
  • Set "noEmitOnError": true so a build with type errors can never produce output that gets deployed or run.
  • Pick a target that matches the oldest runtime you actually support, not the newest syntax you'd like to write — check your deployment environment (Node LTS version, browser support matrix) before choosing.
  • Use tsc --watch (or your bundler's TypeScript integration) during development so type errors show up immediately, not just before a deploy.
  • Remember that compiling only checks types — it does not run your code. Always pair tsc with actual tests; a file can compile cleanly and still have logic bugs.

Practice Exercises

  • Write a tsconfig.json that outputs ES2019-compatible JavaScript into a build/ folder from source files in src/, with strict mode and source maps enabled.
  • Take the multiply function from Mistake 2 and intentionally call it with one string and one number argument. Predict the exact tsc --strict error message before running it, then verify.
  • Write a small enum for traffic light colors (Red, Yellow, Green) and a function that returns how many seconds each color lasts. Then think through, without running anything, what the compiled JavaScript for the enum itself would look like.

Summary

  • tsc compiles (transpiles) .ts files into plain .js files that any JavaScript engine can run.
  • Compiling has two jobs: type-checking your code against the rules you wrote, and emitting JavaScript with the types erased.
  • Type erasure means annotations, interfaces, and type aliases leave no trace in the output — with enum as a notable exception that produces real runtime code.
  • tsconfig.json controls project-wide compiling behavior: target, module, strict, outDir, and more.
  • By default, type errors do not stop JavaScript from being emitted — enable noEmitOnError if you want broken builds to fail outright.
  • Strict mode (noImplicitAny, strictNullChecks, etc.) is what makes TypeScript actually catch mistakes; without it, untyped code silently becomes any.