2.6 The Compilation Output: What tsc Produces
Right, so you’ve run tsc on your index.ts file. You saw it flash by in the terminal, and now you have a new index.js file sitting there. It feels a bit like magic, but we’re not here for magic tricks. We’re here to understand the engineering. Let’s crack open that output file and see what the compiler actually did for us. This is where you go from “it just works” to knowing why it works.
The Most Basic Transformation: Stripping Types
The most obvious job of the TypeScript compiler is to erase your type annotations. They’re for us and the compiler, not for the JavaScript runtime. Let’s look at a dead-simple example.
Our input (index.ts):
let message: string = "I'm a string";
console.log(message);
The output (index.js):
let message = "I'm a string";
console.log(message);
See? The : string is just… gone. It served its purpose during compilation (checking that you only assign a string to message) and then it gets jettisoned. No runtime overhead, no extra bytes sent to the browser. It’s beautifully efficient.
Downleveling: The Time Machine
This is the compiler’s more impressive party trick. “Downleveling” is the act of compiling newer JavaScript (like ES2022) to an older version (like ES5) so it can run in older browsers or environments. The target option in your tsconfig.json controls this. It’s like having a time machine for your code.
Let’s say we use a modern async function and const:
Our input (modern.ts):
const fetchData = async (url: string): Promise<void> => {
// ... modern async stuff
};
Now, watch what happens if we compile with "target": "es5". The output is… a lot.
The output (modern.js):
var fetchData = function (url) {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
// ... transformed async stuff
return [2 /*return*/];
});
});
};
Whoa. What is this __awaiter and __generator nonsense? This is the compiler generating a compatibility layer. Since ES5 didn’t have native async/await, it has to simulate it using a state machine pattern. This output is why you need the TypeScript compiler and don’t just use a linter. It’s actively rewriting your code for a different environment. The good news? If you set "target": "es2017" (which has native async/await), the output is almost identical to the input, just with the types stripped. Always set your target as high as your lowest supported environment allows to avoid this generated bloat.
The Companion Files: .d.ts and .map
When you compile, you might notice tsc doesn’t just spit out a .js file. If you have declaration set to true in your tsconfig.json, it will also generate a .d.ts file (a declaration file). This file contains only the type signatures from your original .ts file. It’s the public API of your module, without any of the implementation junk. Other TypeScript projects that consume your compiled JavaScript can use this .d.ts file to get full type information. It’s like leaving the blueprint next to the finished building.
You might also get a .js.map file if you have sourceMap enabled. This file allows debuggers to map the line in the compiled JavaScript back to the original TypeScript source file it came from. So when your code breaks in the browser, the console can show you the error in your index.ts on line 12, not in the mangled index.js on line 47. It’s an absolute lifesaver. Enable it. Seriously.
Common Pitfalls: Watching the Wrong Folder
Here’s a classic “facepalm” moment I’ve had more times than I care to admit. You run tsc --watch and start furiously editing your src/index.ts file. Nothing happens in your dist/ folder. You restart the compiler, you question your life choices. The issue? You’re not in the root of your project where the tsconfig.json lives, or your config specifies a different root. The compiler is watching some other empty directory, blissfully unaware of your changes. Always double-check your paths. The --watch flag is brilliant, but it can’t read your mind. It only watches what you’ve told it to watch in your config.