TypeScript to JavaScript: How Transpilation Works
See what TypeScript compilation really does: type erasure, downleveling, module output, declarations and source maps, with real tsc output and the pitfalls to avoid.

You write TypeScript, but browsers and Node.js run JavaScript. Somewhere between your editor and the runtime, something has to turn one into the other, and when that step goes wrong the symptoms are confusing. A build succeeds although the types are wrong. An enum behaves strangely. An import vanishes from the output. A bundle ships far more code than the source suggests. This guide explains what "transpilation" really does, step by step, using output from real compiler runs. It is written for developers who use TypeScript daily but have never looked at what comes out the other end, and for anyone who needs to convert a snippet quickly and understand the result. By the end you will know what is erased, what is rewritten, what is added, which tool does which job, and how to set up a build you can trust.
What TypeScript is, and what transpilation means
TypeScript is a typed superset of JavaScript. "Superset" means every valid JavaScript program is also a valid TypeScript program, and TypeScript adds syntax on top: type annotations, interfaces, generics, enums and a few other features. "Typed" means the added syntax lets a tool reason about your code before it runs.
The most important sentence in this article is this one: types exist only at compile time. When the compiler finishes its checks, the type annotations are removed, and what remains is ordinary JavaScript. No type information travels to the browser, and no type checks run in production unless you write them yourself. Developers often call this "type erasure", and it explains most of the surprises later in this guide.
The word "transpilation" is a blend of "transform" and "compilation". A traditional compiler turns source code into a lower-level form such as machine code. A transpiler, also called a source-to-source compiler, turns source code into another language at roughly the same level of abstraction. TypeScript to JavaScript is the classic example. People use "compile" and "transpile" interchangeably for TypeScript, and the official compiler is called tsc, so do not worry about the vocabulary. What matters is the set of jobs the step performs.
The five jobs of a TypeScript build
A TypeScript compiler can do up to five distinct things. It helps to keep them separate in your head, because different tools do different subsets.
- Type checking. Find places where the code contradicts its own declared types, and report errors.
- Type erasure. Remove annotations, interfaces, type aliases and other syntax that has no runtime meaning.
- Downleveling. Rewrite newer language features into older syntax so the output runs on the engines you target.
- Module conversion. Convert
importandexportinto the module format the target environment expects, such as ES modules or CommonJS. - Auxiliary output. Produce declaration files (
.d.ts) that describe your public types, and source maps that connect output lines back to your original code.
The official compiler does all five. Faster tools such as Babel, esbuild and SWC typically do jobs 2, 3 and 4, and skip job 1 entirely. That difference is the single most useful fact for choosing a toolchain, and we return to it in the comparison table.
A short history
Microsoft introduced TypeScript in 2012, designed to make large JavaScript codebases easier to maintain. At that time JavaScript had no native classes, no modules and no static analysis story, and big front-end projects suffered for it. TypeScript offered optional static types with a compiler that emitted plain JavaScript, so adoption could be gradual: rename a .js file to .ts, fix what the compiler finds, and add types where they pay off.
Over the following years JavaScript itself caught up. Classes, modules, arrow functions, async and await, optional chaining and many other features arrived in the language through the standardisation process described in the ECMAScript specification. TypeScript's role shifted. It still ships some syntax of its own, but its main job today is the type system, plus downleveling modern syntax for older targets. The language also follows a design rule that explains much of what you see in the output: TypeScript tries to add types to JavaScript rather than to change how JavaScript behaves at runtime. A few early features, such as enums and namespaces, predate that rule and are the places where emitted code looks least like your source.
The pipeline: parse, bind, check, emit
You do not need to read the compiler's source to benefit from a mental model of it. At a conceptual level, tsc runs four phases.
Parse. The compiler reads your text and builds a tree that represents its structure, called an abstract syntax tree. A statement like const x: number = 1 becomes a variable declaration node with a name, a type annotation node and an initialiser. Parsing only cares about grammar. It does not know whether number is a real type or whether x is used anywhere.
Bind. The compiler walks the tree and creates symbols, which are entries that connect each name to its declarations. This is where it learns that two declarations of the same interface merge into one, or that an identifier in one file refers to an export in another.
Check. The type checker computes the type of every expression and compares it with what you declared or what the context expects. This is the expensive phase, and it is where almost all diagnostics come from: missing properties, incompatible arguments, possibly-undefined values. Everything that depends on types lives here, including narrowing, generics and inference.
Emit. The compiler walks the tree again and prints JavaScript. Type-only nodes are skipped. Syntax newer than your target is rewritten. Imports and exports are converted to the chosen module format. If you asked for them, declaration files and source maps are written alongside.
Two consequences matter in practice.
First, checking and emitting are separable. Emit does not need the checker's answers for most syntax, which is why other tools can produce correct JavaScript without any type checking. It is also why tsc by default writes output files even when it reports errors, a behaviour we measure below.
Second, a handful of emit decisions do depend on type information. The classic example is deciding whether an import is used only as a type. If the compiler cannot see across files, it cannot always know. That tension is behind the isolatedModules and verbatimModuleSyntax options.
Worked example 1: pure erasure
Start with the simplest case. This file uses an interface, a type alias, an optional property, a default parameter and a template literal.
interface User {
id: number;
name: string;
email?: string;
}
type Role = "admin" | "editor";
function greet(user: User, role: Role = "editor"): string {
return `Hello ${user.name} (${role})`;
}
const ada: User = { id: 1, name: "Ada" };
console.log(greet(ada));
I ran tsc basic.ts --target ES2022 with TypeScript 7.0.2. The output was:
"use strict";
function greet(user, role = "editor") {
return `Hello ${user.name} (${role})`;
}
const ada = { id: 1, name: "Ada" };
console.log(greet(ada));
Look at what happened. The User interface and the Role alias are gone completely, because they have no runtime representation. The annotations : User, : Role and : string disappeared. The default parameter stayed, because default parameters are real JavaScript. The template literal stayed too, since the target understands it. The source file was 272 bytes and the output 160 bytes, and nearly all of the difference is types.
Notice the "use strict"; line at the top. The compiler added it. In a script (not an ES module), strict mode changes semantics, so it is worth knowing it is there. When you emit ES modules, modules are strict by definition and the directive is not needed.
This is the foundation of everything else. If your code uses only constructs of this kind, a "TypeScript to JavaScript converter" is mostly a deletion tool. You can try it yourself in the browser with the DevUtilX TypeScript to JavaScript converter, described step by step later.
Worked example 2: downleveling to a target
The target option tells the compiler which JavaScript version the output must run on. Anything newer than the target gets rewritten into older syntax, sometimes with helper functions. This input uses a private class field, a class property, optional chaining, nullish coalescing and async and await.
class Counter {
#count = 0;
step: number = 1;
increment(): number {
this.#count += this.step;
return this.#count;
}
}
async function load(url: string, opts?: { retries?: number }) {
const retries = opts?.retries ?? 3;
const res = await fetch(url);
return { ok: res.ok, retries };
}
With --target ES2022, nothing needs rewriting except the type annotations, and the output is a near copy of the input:
"use strict";
class Counter {
#count = 0;
step = 1;
increment() {
this.#count += this.step;
return this.#count;
}
}
async function load(url, opts) {
const retries = opts?.retries ?? 3;
const res = await fetch(url);
return { ok: res.ok, retries };
}
With --target ES2015, the same file becomes much more elaborate. Here is an excerpt. I omitted the bodies of the helper functions __awaiter, __classPrivateFieldGet and __classPrivateFieldSet, which the compiler emits at the top of the file.
"use strict";
// (helper functions __awaiter, __classPrivateFieldGet and
// __classPrivateFieldSet are emitted here; bodies omitted)
var _Counter_count;
class Counter {
constructor() {
_Counter_count.set(this, 0);
this.step = 1;
}
increment() {
__classPrivateFieldSet(this, _Counter_count, __classPrivateFieldGet(this, _Counter_count, "f") + this.step, "f");
return __classPrivateFieldGet(this, _Counter_count, "f");
}
}
_Counter_count = new WeakMap();
function load(url, opts) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
const retries = (_a = opts === null || opts === void 0 ? void 0 : opts.retries) !== null && _a !== void 0 ? _a : 3;
const res = yield fetch(url);
return { ok: res.ok, retries };
});
}
Four distinct rewrites happened here:
- The private field
#countbecame aWeakMapkeyed by instance, with helper functions to read and write it. This preserves the privacy guarantee on engines that lack native private fields. - The class property
step = 1moved into the constructor as an assignment. With the ES2022 target it stays a field declaration. These two forms behave slightly differently in edge cases, which is why theuseDefineForClassFieldsoption exists and why its default depends on the target. asyncandawaitbecame a generator wrapped in an__awaiterhelper, withyieldstanding in forawait.- Optional chaining and nullish coalescing became explicit comparisons against
nullandundefined.
The lesson is that a lower target means bigger, slower output. Each helper adds bytes, and each rewritten construct runs through more code than the native form. Choosing a target that matches your real audience is a performance decision, not just a compatibility one.
Which features need which target
A smaller experiment shows where the boundaries sit. This file has optional chaining, nullish coalescing, an exponent operator and a class with a static and an instance property:
const o = { a: { b: 1 } } as { a?: { b: number } };
export const v = o.a?.b ?? 0;
export const p = 2 ** 3;
export class K { static s = 1; f = 2; }
I compiled it for three targets with --module esnext. With ES2019, optional chaining and nullish coalescing were rewritten into helper variables and comparisons, and the class fields were converted into constructor and post-class assignments:
var _a;
var _b;
const o = { a: { b: 1 } };
export const v = (_b = (_a = o.a) === null || _a === void 0 ? void 0 : _a.b) !== null && _b !== void 0 ? _b : 0;
export const p = 2 ** 3;
export class K {
constructor() {
this.f = 2;
}
}
K.s = 1;
With ES2020, the ?. and ?? operators stayed as written, but the class fields were still converted:
const o = { a: { b: 1 } };
export const v = o.a?.b ?? 0;
export const p = 2 ** 3;
export class K {
constructor() {
this.f = 2;
}
}
K.s = 1;
With ES2022, class fields were left alone:
const o = { a: { b: 1 } };
export const v = o.a?.b ?? 0;
export const p = 2 ** 3;
export class K {
static s = 1;
f = 2;
}
I wrote a small Python script that compiles the file for each target and reports the output size. The numbers below are from that run on this sample, not a general benchmark:
| Target | Lines of output | Bytes of output |
|---|---|---|
| ES2019 | 11 | 255 |
| ES2020 | 9 | 156 |
| ES2022 | 7 | 130 |
The pattern is consistent: the further the target is below what your code uses, the more the compiler has to write. Also note the exponent operator ** stayed in all three, because it dates from ES2016, older than any target tested here.
One more observation from my installation: asking for --target ES5 failed with error TS5108: Option 'target=ES5' has been removed. That is the behaviour of the 7.0.2 compiler I installed. Older versions accepted ES5, and many existing projects still configure it, so check the documentation for the version you use rather than assuming. The TypeScript documentation lists the supported targets for each release.
Worked example 3: syntax that emits runtime code
Most TypeScript syntax is erased. A small group of constructs is not, because they describe things that exist at runtime. This is where the output stops looking like "your code minus types".
enum Color { Red, Green = "green" }
const enum Dir { Up, Down }
namespace Util {
export const pi = 3.14;
}
class Point {
constructor(public x: number, private y: number) {}
}
console.log(Color.Red, Dir.Up, Util.pi, new Point(1, 2));
Compiled with --target ES2022, the output was:
"use strict";
var Color;
(function (Color) {
Color[Color["Red"] = 0] = "Red";
Color["Green"] = "green";
})(Color || (Color = {}));
var Util;
(function (Util) {
Util.pi = 3.14;
})(Util || (Util = {}));
class Point {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
}
console.log(Color.Red, 0 /* Dir.Up */, Util.pi, new Point(1, 2));
Read it line by line, because each construct teaches something.
Regular enum. A numeric member produces a two-way mapping: Color[Color["Red"] = 0] = "Red" assigns both Color.Red = 0 and Color[0] = "Red". A string member such as Green gets only the forward mapping. The enum is a real object that exists at runtime, which means you can iterate it, pass it around and see it in the bundle. It also means enums add code.
Const enum. Dir.Up was replaced by the literal 0, with a comment, and no Dir object was emitted at all. That is the point of const enum: the values are inlined. The cost is that the object does not exist at runtime, which breaks code that tries to look members up dynamically, and it causes trouble with tools that compile one file at a time, as the next section shows.
Namespace. A namespace becomes an immediately invoked function that attaches members to an object. This is the old "module pattern" from before JavaScript had modules. Modern code uses ES modules instead, and namespaces are rarely needed outside declaration files.
Parameter properties. constructor(public x: number, private y: number) {} is shorthand. The compiler declares two fields and writes the assignments this.x = x; this.y = y; for you. Note that private here is only a compile-time check. At runtime y is a perfectly ordinary, publicly readable property. If you need runtime privacy, use #y, as in the earlier example.
Decorators. Decorators are another runtime feature. I compiled a small class with one method decorator at --target ES2022, and the compiler emitted helper functions named __esDecorate and __runInitializers at the top of the file. Running the output printed call hi and then hi, so the decorator did wrap the method as expected. The helpers are a sign that decorators are not erased, and the emit needs real support code.
The "erasable syntax" idea
Because those four constructs (enums, namespaces with values, parameter properties, and a few legacy forms) need code generation, they cannot be handled by simply deleting text. A compiler option called erasableSyntaxOnly makes the checker reject them. I ran it on the file above, and it reported an error for each one:
ctor.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ctor.ts(2,12): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ctor.ts(3,11): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ctor.ts(7,15): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ctor.ts(7,33): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
The enum, the const enum, the namespace and both parameter properties were flagged. The reason this matters is that some runtimes and tools can run TypeScript by only stripping the types. That approach works if and only if the file contains erasable syntax. If you want your code to survive "strip the annotations and run" tooling, turn this option on and prefer string-literal unions and plain classes over enums and parameter properties.
Worked example 4: modules, declarations and source maps
Module handling is where the same source produces visibly different output. This file has a value import, a type-only import, a value export, a type export and a function that uses the imported type:
import { readFileSync } from "node:fs";
import type { Stats } from "node:fs";
export const read = (p: string): string => readFileSync(p, "utf8");
export type Reader = (p: string) => string;
export function size(s: Stats): number { return s.size; }
With --module commonjs (and Node type definitions installed so node:fs resolves), the output was:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.read = void 0;
exports.size = size;
const node_fs_1 = require("node:fs");
const read = (p) => (0, node_fs_1.readFileSync)(p, "utf8");
exports.read = read;
function size(s) { return s.size; }
//# sourceMappingURL=mod.js.map
With --module esnext the same file became:
import { readFileSync } from "node:fs";
export const read = (p) => readFileSync(p, "utf8");
export function size(s) { return s.size; }
//# sourceMappingURL=mod.js.map
Several things stand out. The import type line is gone in both outputs, and so is the Reader type export. The CommonJS version rewrote import into require, rewrote every use of readFileSync to go through the node_fs_1 object, and attached exports to the exports object. The (0, node_fs_1.readFileSync)(...) pattern calls the function without binding this to the module object, which matches ES module semantics.
Because I passed --declaration and --sourceMap, the compiler also wrote two extra files. The declaration file keeps only the public type information:
import type { Stats } from "node:fs";
export declare const read: (p: string) => string;
export type Reader = (p: string) => string;
export declare function size(s: Stats): number;
This is what other TypeScript projects read when they import your compiled package. The implementation is gone, and the types remain. The type import and the Reader alias are present here, because the declaration file is exactly where types belong.
The source map is a JSON file. Its first fields were:
{"version":3,"file":"mod.js","sourceRoot":"","sources":["../mod.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;..."}
The sources field points back at mod.ts, and mappings is a compact encoding of which generated position corresponds to which original position. Browsers and Node.js use this to show your TypeScript in debuggers and stack traces. Without it, you debug the output.
For background on how the two module systems differ, the MDN guide to JavaScript modules is a clear reference.
Type-only imports and what gets elided
By default, the compiler removes imports that are only used as types. This is called import elision, and it is convenient, but it relies on the compiler knowing, for each imported name, whether it is a type or a value.
I tested it with two files. The first exports an interface and a value:
export interface Config { debug: boolean }
export const defaults: Config = { debug: false };
The second imports both and uses Config only as a type:
import { Config, defaults } from "./types";
import { Config as C2 } from "./types";
export const make = (c: Config = defaults): Config => c;
export type Alias = C2;
Default settings produced this output. Both Config imports were dropped, and defaults stayed:
import { defaults } from "./types";
export const make = (c = defaults) => c;
The same file with --verbatimModuleSyntax failed to compile, with these errors:
use.ts(1,10): error TS1484: 'Config' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
use.ts(2,10): error TS1484: 'Config' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
With the flag on, the rule is "what you write is what you get". Imports and exports without the type modifier are kept exactly as written, and the ones marked type are removed. That makes the output predictable for any tool, with no cross-file guesswork. The fixed version uses an inline type modifier:
import { type Config, defaults } from "./types.js";
export const make = (c: Config = defaults): Config => c;
Its output was import { defaults } from "./types.js"; followed by the same make function. Note that I also changed the specifier to ./types.js. In native ES module projects on Node, import paths must name the file extension of the output file, even though the source file is .ts. This surprises many people the first time.
Why does this matter beyond tidiness? A single-file tool like Babel or esbuild sees only one file at a time. When it reads import { Config } from "./types", it cannot open types.ts to find out whether Config is a type. If it keeps the import, the runtime may fail with "does not provide an export named Config". If it deletes the import, it might delete a real value. The type keyword removes the ambiguity.
Type checking is separate from emitting
Here is the experiment that explains the most real-world confusion. This file has a plain type error:
function double(n: number): number {
return n * 2;
}
const input: string = "21";
console.log(double(input));
Running tsc bad.ts --target ES2022 printed one error and exited with code 2:
bad.ts(5,20): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
But it also wrote bad.js, which looks like this:
"use strict";
function double(n) {
return n * 2;
}
const input = "21";
console.log(double(input));
Running that file with Node printed 42. JavaScript multiplied the string "21" by 2 and coerced it to a number. The type error was real, but it did not stop the output, and the program happened to work anyway. A different input would have produced NaN.
Adding --noEmitOnError changed the behaviour: the compiler printed the same error, exited with code 1, and wrote no output directory at all.
This is the key property to internalise. Emit does not depend on type correctness. If your pipeline only checks the exit code of a bundler, and the bundler strips types without checking, type errors never fail the build. Treat type checking as its own step, and make it a hard gate in continuous integration.
Type checking, narrowing and any versus unknown
Since types vanish at runtime, the way you declare them determines how many bugs the checker can catch before then. Two types are worth understanding well.
any turns the checker off for a value. You can call it, index it, assign it and pass it anywhere, and the compiler will say nothing. It spreads, too: a value derived from an any is usually any as well. A codebase with many any values has types that look reassuring and prove nothing.
unknown is the safe counterpart. It says "this could be anything", and it forces you to check before using the value. You cannot access properties on an unknown until you narrow it.
Narrowing is how the checker follows your runtime checks. Inside an if (typeof v === "string") block, the checker knows v is a string. This function takes unknown and uses typeof checks, which survive compilation because they are ordinary JavaScript:
function parse(v: unknown): number {
if (typeof v === "string") return Number(v);
if (typeof v === "number") return v;
throw new TypeError("unsupported");
}
The compiled output keeps every typeof check and drops only the annotations. That is the pattern to remember: runtime checks written in JavaScript are what narrow types, and they remain in the output. Narrowing is the bridge between a compile-time guarantee and a runtime fact.
Other narrowing forms include instanceof, the in operator, equality checks, truthiness checks, discriminated unions (a shared literal field such as kind) and user-defined type guards, which are functions with return types like v is Api. A type guard is a promise you make to the compiler. If the function body does not actually verify what its signature claims, the checker will trust it anyway.
Types do not validate runtime data
This is the most expensive misunderstanding in TypeScript. A type annotation describes what you expect. It does not enforce it. Data that arrives from outside your program, such as an HTTP response, a file, a message, a URL parameter or local storage, is whatever it is.
I demonstrated it with this file:
interface Api { id: number; name: string }
const raw = JSON.parse('{"id":"7"}') as Api;
console.log(typeof raw.id, parse(raw.id), raw.name.toUpperCase());
It compiles without a single error, because JSON.parse returns any and the as Api assertion tells the checker to believe you. Here is the output after compiling, which shows that the assertion was simply deleted:
const raw = JSON.parse('{"id":"7"}');
console.log(typeof raw.id, parse(raw.id), raw.name.toUpperCase());
Running it crashed with TypeError: Cannot read properties of undefined (reading 'toUpperCase'). The id was a string, not the number the interface promised, and name was missing entirely. The type system never had a chance to notice, because it was not present at runtime.
The fix is to validate at the boundary and let the validation narrow the type. This version accepts unknown, uses a type guard that performs real typeof checks, and reports the problem cleanly:
interface Api { id: number; name: string }
function isApi(v: unknown): v is Api {
return typeof v === "object" && v !== null &&
typeof (v as Record<string, unknown>).id === "number" &&
typeof (v as Record<string, unknown>).name === "string";
}
const data: unknown = JSON.parse('{"id":"7"}');
if (isApi(data)) console.log("valid", data.name.toUpperCase());
else console.log("rejected at the boundary");
Compiled and run, it printed rejected at the boundary. Hand-written guards work for small shapes. For larger ones, teams commonly use a schema validation library that derives both the runtime check and the static type from one definition, which avoids the two drifting apart. Whichever you choose, the principle is the same: validate untrusted data once, at the edge, and trust the types inside.
The same limitation exists in other gradually typed languages. As a comparison, here is Python, where type hints are also advisory. This script annotates a function as taking an int, then calls it with a string:
def double(n: int) -> int:
return n * 2
print(double("21")) # type hints are not enforced at runtime
print(double(21))
Python printed 2121 and then 42. The hint did not stop anything, and "21" * 2 quietly repeated the string. A static checker would flag the first call, exactly as tsc flags the TypeScript version, but neither language enforces the annotation when the program runs.
tsc, Babel, esbuild and SWC: checking versus stripping
Since checking and emitting are separate jobs, the ecosystem split along that line. The official compiler, tsc, is the only tool that implements the full type checker. Several other tools, among them Babel, esbuild and SWC, include TypeScript support that parses and strips types without checking them. They treat annotations as syntax to remove.
Why use a stripper at all? Because it is fast and fits into existing bundlers, and because type checking can run separately and in parallel. A common arrangement is to let a bundler or dev server transform each file quickly using a stripper, while a separate tsc --noEmit process reports type errors in the editor and in CI.
The trade-off is correctness in a few places. A tool that processes one file at a time cannot see other files, so some features need rules:
- Type-only imports must be marked with
type, as shown above, so the tool knows what to delete. const enumcannot be inlined across files by a single-file tool. The TypeScript optionisolatedModulesreports constructs like this so you find out in the editor instead of in production.- Declaration files are not produced by pure strippers, because generating accurate
.d.tsoutput needs type information. Libraries usually runtscfor declarations even when another tool produces the JavaScript. - Language features that were still evolving, such as certain decorator semantics, may differ between tools. Test with your actual toolchain.
The isolatedModules option is a practical guard here. With it on, tsc reports code that cannot be safely transformed one file at a time. In my earlier test, compiling the enum and namespace file with --isolatedModules produced error TS1280: Namespaces are not allowed in global script files when 'isolatedModules' is enabled, because that file had no imports or exports and therefore counted as a global script. The remedy is to make the file a module, for example by adding export {}, or to avoid namespaces.
The tsconfig options that matter
A tsconfig.json file controls the compiler. There are many options, and most projects need only a handful. The tsconfig reference documents all of them. These are the ones that change emitted output or the reliability of your build.
| Option | What it controls | Practical advice |
|---|---|---|
target |
Which JavaScript version the output must run on, and therefore which syntax gets downleveled | Match your real minimum runtime. A needlessly low target inflates output, as the size table above shows |
module |
The module format of the output, such as ES modules or CommonJS | Use ES modules for browsers and bundlers. Match what your runtime or bundler expects |
moduleResolution |
How the compiler finds the file behind an import specifier | Pick the mode that matches your runtime or bundler. A mismatch causes imports that compile but fail at runtime |
strict |
Turns on a family of stricter checks, including null checking | Start new projects with it on. For older code, adopt it gradually |
lib |
Which built-in type definitions are available, such as DOM or newer standard library features | Types for APIs only. It does not add polyfills, so the runtime must still provide them |
sourceMap |
Writes .map files that link output to source |
Enable for development and for production error reporting |
declaration |
Writes .d.ts files |
Needed for libraries that other TypeScript code will import |
isolatedModules |
Flags code that cannot be transformed one file at a time | Turn on whenever another tool, not tsc, produces your JavaScript |
verbatimModuleSyntax |
Keeps imports and exports exactly as written unless marked type |
Makes output predictable and removes import-elision guesswork |
noEmit |
Runs checks and writes nothing | Use in CI and editors when a bundler produces the output |
noEmitOnError |
Skips emit if any error was found | Useful when tsc itself is your build |
erasableSyntaxOnly |
Rejects syntax that needs code generation | Use for code meant to run by type stripping alone |
Two of these deserve extra care.
lib is not a polyfill. Setting lib to a recent version lets you write Array.prototype.at or other newer APIs without type errors. It does nothing to make those APIs exist in an old browser. Downleveling rewrites syntax, not APIs. If you target an environment that lacks a method, you need a polyfill or a library that provides one.
module and moduleResolution must agree with reality. The compiler resolves imports according to the mode you choose, then checks types against whatever it found. If it resolves a path in a way your bundler or Node.js will not, the build passes and the program fails when it runs. Pick the settings recommended for your runtime in the documentation, and test the real build output, not just the type check.
A compact, reasonable starting configuration for a project where a bundler produces the JavaScript and tsc only checks looks like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"include": ["src"]
}
I used a variant of this to confirm that tsc -p . reports the type error and exits non-zero with noEmit on. That is the behaviour a CI check wants. The types entry matters in the version I tested: without it, the compiler did not pick up the Node.js type definitions, and node:fs imports failed with TS2591. Installing @types/node and listing it fixed that.
Real-world use cases
Everyday application builds. Most front-end and back-end TypeScript projects use a bundler or runner that strips types for speed, with tsc --noEmit as the type gate. Understanding the split tells you why a green dev server and a red type check can coexist.
Publishing a library. A package author typically ships compiled JavaScript plus .d.ts files. Consumers who use JavaScript get working code, and consumers who use TypeScript get types. Declaration generation is the reason libraries still run tsc somewhere in the pipeline.
Targeting older environments. An application with a long-tail browser audience or an old Node.js version picks a lower target and accepts larger output. Knowing exactly which features trigger helpers lets you measure the real cost of that choice.
Gradual migration from JavaScript. Teams convert a codebase file by file, because any JavaScript is valid TypeScript. A compiler option that lets TypeScript check JavaScript files is a common stepping stone, as is renaming files and adding annotations to the highest-risk modules first.
Learning and debugging. Pasting a snippet into a converter is a fast way to see what your syntax compiles to. It answers questions such as "does this enum add runtime code?" or "what does my decorator become?" without setting up a project.
Sharing runnable examples. Documentation, tutorials, issue reports and code reviews often include TypeScript. A plain JavaScript version lets readers run it in a browser console or a quick Node.js script, with no toolchain.
Reviewing generated output. When two builds differ or a bundle grows unexpectedly, comparing the emitted JavaScript shows what changed. Our guide to comparing files and code diffs explains how to read such diffs well.
Step-by-step: convert TypeScript with DevUtilX
For a quick conversion, with no project, no install and no configuration, use the DevUtilX TypeScript to JavaScript converter. It runs in your browser.
- Open the TypeScript to JavaScript tool.
- Paste your TypeScript into the input editor. A single function, a class or a whole module all work.
- Run the conversion.
- Read the JavaScript output. Check that the types are gone and that the logic is unchanged.
- Copy the result into your console, script, issue report or documentation.
Your code is processed locally in your browser and is not uploaded, which matters when the snippet contains internal names, proprietary logic or customer data. Even so, avoid pasting real secrets or credentials into any tool.
Use the tool for what it is good at, and know its limits. A converter that works on a single snippet performs the stripping and rewriting jobs from the list near the start of this article. It does not know about your other files, your tsconfig.json or your installed type packages. So it cannot do project-wide type checking, and it cannot tell you that an imported name is wrong. If you paste code that imports from other modules, expect the imports to appear in the output as written, and remember that value-versus-type decisions are easier when you mark type imports with type. If the output looks wrong or the tool reports a problem, look for a syntax error first: an unclosed brace, a stray character, or code that is not valid TypeScript at all.
After converting, you may want to tidy or shrink the result. Our guide to JavaScript code formatting best practices covers readable style, and JavaScript minification explained describes how to make the output smaller for production.
Running the compiler yourself
Everything in this article came from the official compiler, and you can reproduce it in a minute. Install it as a development dependency, then compile with flags or a config file:
npm install --save-dev typescript
npx tsc --version
npx tsc src/index.ts --target ES2022 --module esnext --outDir dist
npx tsc --noEmit # type check only, using tsconfig.json
Comparing targets automatically
I used this Python script to compile one file for several targets and report the output size. It shells out to tsc, so it works with whatever compiler version is installed:
import subprocess, sys, pathlib, tempfile
src = sys.argv[1]
for target in ("ES2019", "ES2020", "ES2022"):
out = tempfile.mkdtemp()
subprocess.run(
["npx", "tsc", src, "--target", target, "--module", "esnext", "--outDir", out],
check=True,
)
js = (pathlib.Path(out) / (pathlib.Path(src).stem + ".js")).read_text()
print(target, len(js.splitlines()), "lines,", len(js), "bytes")
Running it on the small feature file from earlier printed ES2019 11 lines, 255 bytes, ES2020 9 lines, 156 bytes and ES2022 7 lines, 130 bytes. The same idea works in a build script that tracks output growth over time.
Using the compiler as a library
Node.js code can call the compiler's transpile function directly when it wants single-file stripping with no checking. The function takes source text and options and returns JavaScript. This is the same split described above: it transforms one file in isolation and does not report type errors.
const ts = require("typescript");
const result = ts.transpileModule(
"const n: number = 1; export const double = (x: number): number => x * 2;",
{ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }
);
console.log(result.outputText);
Treat that function as a stripper, not a checker. It is convenient for tests, scripts and tools, and it behaves like the single-file tools discussed earlier.
Common mistakes and pitfalls
- Assuming types protect production. They do not exist there. Validate untrusted data at boundaries, as in the
isApiexample. - Using
asto silence the compiler. A type assertion does not convert or check anything. It deletes at compile time and leaves the runtime value untouched. - Treating
privateas security. The keyword is a compile-time check only. Use#fieldfor runtime privacy, and never keep secrets in client-side objects. - Trusting a green build when the bundler strips types. If nothing runs
tsc, nothing checks types. Add a dedicated type-check step. - Reaching for enums by habit. Enums emit objects and have a two-way mapping for numeric members. A union of string literals is erased completely and often does the same job.
- Using
const enumwith single-file tools. The inlining cannot always happen, and the member lookup may fail at runtime. EnableisolatedModulesto find these early. - Importing types without
type. This works until a tool cannot tell a type from a value. Useimport typeor inlinetype, and considerverbatimModuleSyntax. - Forgetting file extensions in native ES module imports. The specifier must name the output file, such as
./types.js, even if the source istypes.ts. - Believing
libadds features. It adds type definitions only. A missing runtime method still throws. - Setting
targettoo low "to be safe". Output grows, and helpers add runtime cost. Choose the lowest target you actually support. - Shipping without declaration files. Library consumers using TypeScript lose all types if you only publish JavaScript.
- Ignoring declaration generation errors. A build that emits JavaScript can still fail to produce correct
.d.tsfiles. Check both. - Mixing
moduleandmoduleResolutionsettings carelessly. The type check passes, and the program fails when it loads. Test the built output. - Using
anyas a default. Oneanycan quietly disable checking across many dependent values. Preferunknownand narrow.
Performance, security and accessibility considerations
Performance
Transpilation affects performance in three ways. Compile time depends mostly on type checking. Strippers are fast because they skip the checker, which is why bundlers use them in development, and why many teams run the checker in a separate process. Output size depends on your target and on constructs that emit helpers, as the downleveling example showed. Runtime speed depends on whether the engine sees modern syntax it can optimise or helper-based code it must run as written. For most applications the dominant costs are elsewhere, such as network and framework code, so measure before optimising. Once output is correct, minification shrinks it further; see our guide to JavaScript minification.
Importing only what you use also helps. ES module output lets bundlers drop unused exports, a technique often called tree shaking. CommonJS output makes that analysis much harder, because require calls and exports assignments are dynamic. If bundle size matters, emit ES modules and let the bundler produce the final format.
Security
- Types are not validation. Never rely on an interface to reject malicious or malformed input. Validate on the server and at every trust boundary.
- Source maps expose your source. If you publish them with a production site, anyone can read your original TypeScript, including comments. Host them privately and upload them to your error tracker, or decide deliberately to ship them.
- Do not paste secrets into converters. Even a local tool is not a place for credentials.
- Dependencies carry risk. Type packages and build tools are code that runs on your machine and in CI. Pin versions and review updates.
- Strict null checks prevent a class of bugs that often turn into security issues, such as an unexpected
undefinedskipping an authorisation branch. Strictness helps, but it never replaces runtime checks.
Accessibility and developer experience
TypeScript output is not itself an accessibility concern, but the pipeline affects the people who build and use your product. Accurate source maps let developers debug assistive-technology bugs against the original code. Typed DOM APIs catch some mistakes, such as treating an element that may be null as always present, though they do not check whether your markup is accessible. Run accessibility testing tools on the rendered page. For markup guidance, see our HTML formatting and semantic markup guide.
CI and build setup
A reliable setup separates the jobs and makes the checking job impossible to skip.
- Type check as its own step. Run
tsc --noEmitin CI and fail the pipeline on any error. This is your safety net, and it catches the "green bundler, wrong types" case. - Produce JavaScript with the tool that suits the project. That might be
tsc, or a bundler that strips types. If it strips types, turn onisolatedModulesso unsafe constructs are flagged. - Generate declarations for libraries. Run
tsc --declaration --emitDeclarationOnlywhen another tool emits the JavaScript, and publish both outputs. - Emit source maps and send them to your error monitoring service.
- Pin the compiler version. Different releases can change defaults, remove options (as the target example showed) or alter emit details. Upgrade deliberately, and review the output diff when you do.
- Test the built artifacts. Import the compiled package in a small script or test, in the same module format your users will use. This catches resolution and extension mistakes that type checking cannot.
A small package.json scripts block that follows this plan:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --declaration --outDir dist",
"test:dist": "node --eval \"import('./dist/index.js').then(m => console.log(Object.keys(m)))\""
}
}
Run typecheck on every push, build before publishing, and test:dist as a smoke test of what users will actually load.
Troubleshooting guide
Work from the symptom to the cause.
Symptom: the build succeeds but the code crashes with a type-related error
Cause. The data at runtime did not match the declared type. Annotations and as assertions are erased, so nothing checked the value.
Fix. Validate at the boundary with a type guard or schema library. Replace as with narrowing, and prefer unknown over any for external data.
Symptom: tsc reports errors but output files appear anyway
Cause. Emit does not depend on type correctness unless you ask it to. By default the compiler writes output even when the checker finds errors.
Fix. Add noEmitOnError, or make CI fail on a non-zero exit code from tsc --noEmit.
Symptom: a type error exists but the bundler or dev server shows nothing
Cause. The tool strips types without checking them.
Fix. Run tsc --noEmit separately, in your editor and in CI.
Symptom: "does not provide an export named" or a missing import at runtime
Cause. An import that is only a type was kept, or a value import was removed, because a single-file tool could not tell them apart.
Fix. Use import type or inline type modifiers, and enable verbatimModuleSyntax or isolatedModules so the compiler reports the ambiguous cases.
Symptom: a const enum is undefined at runtime or errors under isolatedModules
Cause. const enum relies on inlining across files, which per-file tools cannot do.
Fix. Use a regular enum, or better, a string-literal union with a constant object.
Symptom: the output has a large block of helper functions at the top
Cause. The target is older than the syntax you use, so the compiler adds helpers for features such as async, class private fields or decorators.
Fix. Raise the target if your environments allow it. If not, accept the cost, or reduce how many helpers you need by using fewer such features.
Symptom: Cannot find name 'node:fs' or missing globals such as process
Cause. The matching type definitions are not installed or not included. In the version I tested, listing them in types was required.
Fix. Install the type package for the environment (for example @types/node) and add it to the types option.
Symptom: Option 'target=ES5' has been removed
Cause. A newer compiler version dropped a target that older projects relied on.
Fix. Raise the target to one the installed version supports, and adjust your browserslist or runtime support policy to match. Check the release documentation before upgrading.
Symptom: runtime error "Cannot find module" with a path that type checks fine
Cause. Module resolution mode or file extensions do not match what the runtime does. Native ES modules need the output extension in the specifier.
Fix. Write ./file.js in imports, and choose module and moduleResolution settings that match your runtime or bundler.
Symptom: a newer method like Array.prototype.at compiles fine but fails in an old browser
Cause. lib enabled the types, but downleveling does not add APIs.
Fix. Add a polyfill, or avoid the API for that audience.
Symptom: stack traces show compiled lines, not TypeScript
Cause. Source maps are missing, not loaded, or not supported by the runtime setting.
Fix. Enable sourceMap, keep the .map files next to the output, and in Node.js enable source map support for stack traces.
Best practices checklist
- Treat checking and emitting as separate jobs and make sure something enforces the first.
- Run
tsc --noEmitin CI and fail on errors. - Enable
strictin new projects, and adopt it incrementally in existing ones. - Pick
targetfrom your real support matrix, not from habit. - Enable
isolatedModuleswhenever another tool produces your JavaScript. - Use
import typefor anything that is only a type. - Consider
verbatimModuleSyntaxto make import behaviour explicit. - Prefer string-literal unions over enums unless you need a runtime object.
- Prefer
#privatefields when runtime privacy matters. - Validate external data at boundaries, and use
unknownplus narrowing instead ofanyandas. - Ship
.d.tsfiles for libraries, and verify they are correct. - Emit ES modules when a bundler will finish the job, so unused code can be removed.
- Generate and handle source maps deliberately.
- Pin your compiler version and review output diffs when upgrading.
- Test the built output, in the same module format your users will load.
- Keep generated files out of hand-edited code. Fix the source, then rebuild.
Comparison: ways to turn TypeScript into JavaScript
| Approach | Type checking | Downleveling | Declarations | Speed | Best for |
|---|---|---|---|---|---|
tsc |
Yes, full | Yes | Yes | Slower on large projects | Libraries, CI gates, projects where one tool must do everything |
| Babel with the TypeScript preset | No | Yes, plugin-driven | No | Moderate | Projects already built on Babel with its plugin ecosystem |
| esbuild | No | Yes, within its supported range | No | Very fast | Bundling and dev servers where checking runs separately |
| SWC | No | Yes | Limited, so check its documentation for your needs | Very fast | Large builds that want speed, with checking run elsewhere |
| Type stripping by the runtime | No | No | No | Immediate | Scripts and tools that use only erasable syntax |
| Online converter such as DevUtilX | No project-wide checking | Snippet-level | No | Instant | Quick conversions, learning, sharing examples |
Speed descriptions here are qualitative. Actual numbers depend on your project and hardware, so measure your own build before choosing. Many teams combine a fast stripper for output with tsc --noEmit for checking and tsc --emitDeclarationOnly for declarations, which gets the strengths of each.
How the pieces fit together
It helps to see one file travel through the whole pipeline. Suppose user.ts defines an interface, a function that uses it, and an exported constant. The parser produces a tree containing both type nodes and value nodes. The binder records the interface, the function and the constant as symbols. The checker computes types for every expression and reports any mismatch.
If a declaration file was requested, one pass writes the public type shape of the module to user.d.ts, keeping the interface and removing the function body. In the emit pass, interface and annotation nodes are dropped, newer syntax is rewritten for the target, and the module syntax is converted to the requested format. A source map records how output positions relate to input positions. At the end you hold up to three artifacts for each file: JavaScript for runtimes, declarations for other TypeScript code, and a map for debuggers.
This model also explains why a stripper can be so fast. It does the parse and emit passes and skips the bind and check passes, which hold nearly all of the cost on large projects. It also explains what a stripper must give up: anything that needs the checker's knowledge, which is mainly declaration output and cross-file type decisions.
Why the same source can produce different output
Because emit depends on options, identical source can legitimately produce different JavaScript. In this article you saw three effects: target changed whether ?. and class fields were rewritten, module changed import into require, and verbatimModuleSyntax changed which imports were kept or rejected. When two teammates get different output from the same file, compare their compiler versions and configuration first. Our code diff guide is useful for that comparison.
FAQ
Is TypeScript compiled or transpiled?
Both words are used. The official tool is called a compiler, and since it turns TypeScript into JavaScript at a similar level of abstraction, "transpiler" is also accurate.
Does TypeScript make my code run faster or slower?
Types themselves add no runtime cost, because they are erased. Output speed depends on the target and on constructs such as enums or decorators that emit extra code.
Can I run TypeScript directly without compiling?
Some runtimes can run TypeScript files by stripping types on the fly. That only works for code that uses erasable syntax, so features like enums and parameter properties may not be supported. The erasableSyntaxOnly option helps you stay within that subset.
Why did the build succeed even though I have type errors?
Emit does not require type correctness by default, and many bundlers strip types without checking. Run tsc --noEmit and fail your CI on its exit code.
What is the difference between any and unknown?any disables checking for a value. unknown accepts any value but forces you to narrow it before using it, so it keeps you honest.
Do enums exist at runtime?
Regular enums do. They compile to an object, and numeric members get a reverse mapping. const enum members are inlined and no object is emitted. A union of string literals is completely erased.
What does import type do?
It tells the compiler and any other tool that the import is only used for types and can be deleted from the output. It removes ambiguity for tools that cannot see other files.
Do I need declaration files?
Only if other TypeScript code will consume your compiled output, which is the case for published libraries. Applications usually do not need them.
What does lib do?
It chooses which built-in type definitions are available. It does not add runtime features, so you may still need polyfills.
What is a source map and do I need one?
It is a file that maps generated code back to your original source, so debuggers and error reports can show TypeScript lines. It is strongly recommended for debugging, and you should decide deliberately whether to publish it.
Is it safe to paste my code into an online converter?
Only if it runs in your browser. The DevUtilX converter processes your input locally and does not upload it, but never paste secrets, keys or personal data into any tool.
Does TypeScript add polyfills for older browsers?
No. It rewrites syntax for your target and adds small helpers for some constructs, but it does not provide missing built-in methods. Use a polyfill or a library for those.
Should I use tsc, Babel, esbuild or SWC?
Choose by the jobs you need. Only tsc gives you full type checking and reliable declarations. The others are fast strippers, best paired with a separate tsc --noEmit step.
Why does my import need a .js extension when the file is .ts?
In native ES module projects, the specifier must match the file that exists after compilation. The compiler resolves ./types.js to types.ts while type checking, and the emitted code keeps the .js path that the runtime can load.
Can the output be converted back to TypeScript?
No. The types were erased, so the information is gone. Keep the TypeScript source in version control, and treat the generated JavaScript as build output.
Conclusion
Transpilation is less mysterious once you split it into its jobs. Types are checked, then erased. Newer syntax is rewritten for the target, and module syntax is converted. Declarations and source maps are written when you ask for them. A few constructs, namely enums, namespaces, parameter properties and decorators, emit real runtime code, and everything else vanishes. Above all, remember that a type annotation is a promise made at compile time, and the runtime never sees it, so validate untrusted data and keep a type-checking step in CI.
For a quick conversion with no setup, paste your snippet into the DevUtilX TypeScript to JavaScript converter and read the output. To keep going, read our guides on JavaScript code formatting best practices and JavaScript minification explained.


