Topic 12.3
target, lib and Emit
In one line
target decides how new JavaScript syntax is rewritten for older engines, lib decides which built-in names the checker knows about, and the emit options decide what files tsc writes and where. These three are separate, and mixing them up causes most confusing build errors.
Think of it like this
You are sending a recipe to three friends. One has a modern kitchen, one an old stove, one only a campfire. **target is which friend you write for: for the campfire friend you rewrite "use the air fryer" as plain steps. lib** is the list of tools you are allowed to *mention* in the recipe at all. And emit is where the printed copies go. A recipe can mention an air fryer (lib) to a friend who doesn't own one (target and runtime); nobody checks the kitchen.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- target
- The JavaScript version tsc writes. Newer syntax is rewritten to fit it.
- Downlevel
- Rewrite newer syntax into older, longer code that does the same thing.
- lib
- Which built-in names (Array, Promise, document...) the checker knows. Types only: no code is added.
- Polyfill
- A small script that defines a missing built-in function at runtime. tsc never adds one for you.
- Emit
- The files tsc writes: .js, .js.map, .d.ts.
- outDir / rootDir
- outDir is where output goes. rootDir is the source folder that is mirrored inside it.
- Source map
- A file that maps lines of the output JavaScript back to lines of your TypeScript, for debuggers and stack traces.
- noEmitOnError
- Write nothing at all if there is any type error.
- useDefineForClassFields
- Chooses whether class fields use the modern define semantics (true) or are just assigned in the constructor (false).
Step by step
01One source file, three targets
Here is a tiny file: a class with a #private field, an async function with ?. and ??, and a **. We compile it with tsc --target <t> for three targets. Look at what changes. (The helper functions at the top are trimmed with ....)
class Counter {
#count = 0;
step = 1;
next(): number {
this.#count += this.step;
return this.#count;
}
}
export async function cityOf(user: User): Promise<string> {
const city = user.address?.city ?? "unknown";
await Promise.resolve();
return city;
}
export const squares = [1, 2, 3].map((n) => n ** 2);02target es2015: async and ?. are rewritten
async/await becomes a generator (function* and yield) run by a helper __awaiter. ?. and ?? become explicit null checks. ** becomes Math.pow. #count becomes a WeakMap with get/set helpers. It works on old engines, and the output is much longer.
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { ... };
var __classPrivateFieldGet = ... ;
var __classPrivateFieldSet = ... ;
var _Counter_count;
class Counter {
constructor() {
_Counter_count.set(this, 0);
this.step = 1;
}
next() {
__classPrivateFieldSet(this, _Counter_count, __classPrivateFieldGet(this, _Counter_count, "f") + this.step, "f");
return __classPrivateFieldGet(this, _Counter_count, "f");
}
}
_Counter_count = new WeakMap();
export function cityOf(user) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
var _b;
const city = (_b = (_a = user.address) === null || _a === void 0 ? void 0 : _a.city) !== null && _b !== void 0 ? _b : "unknown";
yield Promise.resolve();
return city;
});
}
export const squares = [1, 2, 3].map((n) => Math.pow(n, 2));03target es2017 and es2020: less to rewrite
At es2017 async/await is native, so the generator helper disappears and only ?./?? are still rewritten. At es2020 those are native too, but #private fields (ES2022) still need the WeakMap helpers.
export async function cityOf(user) {
var _a;
var _b;
const city = (_b = (_a = user.address) === null || _a === void 0 ? void 0 : _a.city) !== null && _b !== void 0 ? _b : "unknown";
await Promise.resolve();
return city;
}
export const squares = [1, 2, 3].map((n) => n ** 2);04target es2022: your source, types removed
Nothing needs rewriting, so the output is your code with the type annotations and the interface deleted. This is the shape you want when your engines are modern.
class Counter {
#count = 0;
step = 1;
next() {
this.#count += this.step;
return this.#count;
}
}
export async function cityOf(user) {
const city = user.address?.city ?? "unknown";
await Promise.resolve();
return city;
}
export const squares = [1, 2, 3].map((n) => n ** 2);05target changes meaning: class fields
The next file redeclares an inherited field without a value. For target: es2020 the emitted class has no sides line, so 4 from the base class survives. For es2022 it has sides; which defines the field as undefined after super(). TypeScript warns with TS2612 when the second behaviour applies.
$ tsc --ignoreConfig --target es2020 --outDir out-es2020 src/c2.ts
$ cat out-es2020/c2.js
class Shape {
constructor() {
this.sides = 4;
}
}
class Triangle extends Shape {
constructor() {
super();
}
}
console.log(new Triangle().sides); // 4
$ tsc --ignoreConfig --target es2022 --outDir out-es2022 src/c2.ts
src/c2.ts(5,3): error TS2612: Property 'sides' will overwrite the base property in 'Shape'. If this is intentional, add an initializer. Otherwise, add a 'declare' modifier or remove the redundant declaration.
$ cat out-es2022/c2.js
class Shape {
sides = 4;
}
class Triangle extends Shape {
sides;
constructor() {
super();
}
}
console.log(new Triangle().sides); // undefined06lib: too small a list
A file that uses scores.at(-1) and document.title. With target: es2022 and no lib, it is clean. With target: es2020 and no lib, at is missing but the DOM is still known. With lib: ["es2020"] you replace the default list, so even console and document vanish.
Read the error text: "Do you need to change your target library?" is the compiler suggesting lib, not target.
# "target": "es2020", "lib": ["es2020"]
$ tsc
main.ts(2,1): error TS2584: Cannot find name 'console'. Do you need to change your target library? Try changing the 'lib' compiler option to include 'dom'.
main.ts(2,20): error TS2550: Property 'at' does not exist on type 'number[]'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2022' or later.
main.ts(3,1): error TS2584: Cannot find name 'console'. Do you need to change your target library? ...
main.ts(3,13): error TS2584: Cannot find name 'document'. Do you need to change your target library? ...
# "target": "es2020", "lib": ["es2020", "dom"]
$ tsc
main.ts(2,20): error TS2550: Property 'at' does not exist on type 'number[]'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2022' or later.
# "target": "es2022", no lib
$ tsc
(no output, exit 0)07Where files land: outDir and rootDir
A project with src/math.ts and test/math.test.ts, outDir: "dist", module: "nodenext" and sourceMap: true. With both folders included, tsc mirrors the folder structure under dist.
Include only src and leave rootDir out: you get TS5011 and the output is still written to dist/src/.... Say rootDir: "src" and it becomes dist/math.js. The source map names its source file relative to the output.
# include: ["src", "test"], no rootDir
$ tsc && find dist -type f
dist/src/math.js
dist/src/math.js.map
dist/test/math.test.js
dist/test/math.test.js.map
# include: ["src"], no rootDir
$ tsc
tsconfig.json(5,5): error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout.
Visit https://aka.ms/ts6 for migration information.
# add "rootDir": "src"
$ tsc && find dist -type f
dist/math.js
dist/math.js.map
$ cat dist/math.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.add = void 0;
const add = (a, b) => a + b;
exports.add = add;
//# sourceMappingURL=math.js.map
$ cat dist/math.js.map
{"version":3,"file":"math.js","sourceRoot":"","sources":["../src/math.ts"],"names":[],"mappings":"..."}08Exit codes and noEmitOnError
Add src/bad.ts with export const bad: number = "x";. By default tsc reports the error, still writes dist/bad.js, and exits with code 2. With noEmitOnError: true it writes nothing and exits with 1. CI should look at the exit code, not at whether files exist.
$ tsc ; echo "exit $?"
src/bad.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.
exit 2
$ find dist -type f
dist/bad.js
dist/bad.js.map
dist/math.js
dist/math.js.map
# with "noEmitOnError": true
$ tsc ; echo "exit $?"
src/bad.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.
exit 1
$ ls dist
ls: cannot access 'dist': No such file or directory09es5 and friends are gone in 7.0
A config written for TypeScript 5: target: es5 and baseUrl. TypeScript 6.0.3 reports both as deprecated and tells you how to silence them. TypeScript 7.0.2 reports them as removed, even with ignoreDeprecations. This is a non-live block on purpose: the in-browser checker runs 6.0 and would not show the 7.0 error.
# tsconfig.json has "target": "es5" and "baseUrl": "./src"
$ node node_modules/typescript-6/bin/tsc
tsconfig.json(3,15): error TS5107: Option 'target=ES5' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
tsconfig.json(6,5): error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
Visit https://aka.ms/ts6 for migration information.
$ node node_modules/typescript-7/bin/tsc
tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(6,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
Use '"paths": {"*": ["./src/*"]}' instead.
# add "ignoreDeprecations": "6.0"
$ node node_modules/typescript-6/bin/tsc # exit 0, and dist/main.js has: export var hi = "hi";
$ node node_modules/typescript-7/bin/tsc # the same two TS5108 / TS5102 errors, unchangedTry it yourself
- 1
Break the lib
The live example above uses the default libraries. In a real project, set
"lib": ["es2020"]and runtsc: predict the errors before you read the terminal step. You'll loseat(ES2022) and, becauselibreplaces the default list,consoleanddocumenttoo.Then change the list to
["es2022", "dom"]and the file is clean. In the browser example here, you can check theatresult yourself by replacingscores.at(-1)withscores[scores.length - 1]: the type becomesnumber | undefinedtoo, because ofnoUncheckedIndexedAccess. - 2
Fix the field properly
In the second example, change
sides!: number;todeclare sides: number;. The warning goes away and the console prints4:declaresays "this is only a type, emit nothing", which is exactly what you meant in the first place and works the same on every target. - 3
Pick the exit code
Take any project with a deliberate type error and run
tsc; echo $?twice: with and withoutnoEmitOnError. Before running it, predict both exit codes and whetherdist/exists. (2 and yes, then 1 and no.)
Code & diagrams
With the defaults from tsc --init the checker knows Array.prototype.at (ES2022) and document (the DOM). Both names come from lib files, not from your code. at() can return undefined for an empty array, which is why last is number | undefined. The terminal session above shows the same file failing when lib is too small.
Console
(nothing was printed)Expected console output
90 trueThis example uses the defaults of tsc --init, whose target is esnext, so useDefineForClassFields is true. The field sides! in Triangle is defined as undefined after super() runs, and TypeScript warns (TS2612). Next, the same file with the old behaviour.
Console
(nothing was printed)Expected console output
undefinedThis is what an older target (below ES2022) gives you by default: the redeclaration emits nothing, so the 4 from the base class survives, and TypeScript no longer warns. The live preview always emits for a modern engine, so we switch the option directly instead of lowering target. For a real target change, see the terminal session above.
Console
(nothing was printed)Expected console output
4Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget rootDir
Set outDir: "dist", include only src, and leave out rootDir.
Break #2
Keep target es5 in an old config
Run TypeScript 7.0.2 on a tsconfig that still says "target": "es5".
Myth vs fact
Myth
target makes new built-ins like Array.prototype.at work on old browsers.
Fact
It only rewrites syntax. Library functions are the runtime's job (or a polyfill's). lib just tells the checker the name exists.
Myth
If tsc reports errors, it writes nothing.
Fact
By default it still writes JavaScript. Use noEmitOnError to stop that, and read the exit code (0 clean, 2 errors with output, 1 errors without output).
Myth
lib is added to the default list.
Fact
It replaces the default. Writing lib: ["es2022"] removes the DOM, so document and even console become unknown names.
Myth
A lower target just makes the output longer.
Fact
It can also change behaviour: class fields (useDefineForClassFields) and some iteration details differ between targets.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Helper functions like
__awaiterare emitted into every file that needs them unless you setimportHelpersand installtslib. Large downlevelled projects ship megabytes of repeated helpers; in modern targets the problem mostly disappears, which is another reason to raisetarget. - ▸
Bundlers (esbuild, Vite, SWC) take their own
targetand do the syntax lowering themselves, so with them thetsctargetmostly decides what the checker assumes and how class fields behave, not the final output. Keep the two settings in agreement. - ▸
libalso holds the version-specific pieces thattargetis not responsible for, likelib.esnext.disposable(the types forusing, TypeScript 5.2) or, before 6.0,lib.dom.iterable(iterating aNodeList; 6.0 folded it intolib.domand left an empty file you may still list). If you see "Do you need to change your target library?" (TS2550,TS2584), the answer is almost alwayslib, nottarget. - ▸
The
.js.mapfile'ssourcespath is relative to the output file, so movingdistto another folder without moving the source (or serving.tsin production) breaks debugging silently. SetsourceRootorinlineSourceswhen you deploy maps separately.
Remember this
- 1
The problem this solves: the JavaScript you write may need to run on engines (browsers, Node versions) that are older or newer than the syntax you used, and you also need to decide where the output files go.
target,liband the emit options settle this. **target** is the version of JavaScript thattscwrites. Syntax newer than the target is downleveled: rewritten into older, longer code that behaves the same. Withtarget: "es2015"the arrow functions stay, butasync/awaitbecomes a generator wrapped in a helper,?.and??become long=== null || === void 0checks,**becomesMath.pow, and#privatefields becomeWeakMaphelpers. Withtarget: "es2022"almost nothing is rewritten. Settargetto the oldest engine you really must run on, and no older. Since TypeScript 6.0 (and in 7.0) the defaulttargetis the newest stable ECMAScript version, not the old ES5. - 2
**
libis the set of type declarations for built-in objects**:Array,Promise,Map,document,console.targetpicks a defaultlib(the standard library of that ECMAScript version, plus the DOM and related browser APIs when you don't list one). If you writelibyourself, you replace the default entirely. Crucially,libis only types: it never adds code.[1, 2].at(-1)needslibto include ES2022; and even when the checker is happy, the runtime must haveArray.prototype.at. Making old runtimes have it is the job of a polyfill (a small script that defines the missing function), not oftsc. - 3
This is the difference between the two kinds of "new": syntax (
async,?., classes,#x) can be rewritten by the compiler, sotargethandles it. Library features (Array.prototype.at,Object.hasOwn,structuredClone) are functions that either exist at runtime or don't, so the compiler can only warn:libdecides whether the checker accepts the name, and you decide whether the runtime has it. The error for a missing one isTS2550: Property 'at' does not exist on type 'number[]'. Do you need to change your target library?. - 4
targetalso changes meaning, not just spelling. Class fields are the famous case. Since ES2022,sides!: numberin a subclass really defines a field that is set toundefinedaftersuper()runs. The old TypeScript behaviour just wrote nothing. The option **useDefineForClassFields** picks which one you get, and its default istruewhen the target is ES2022 or newer (including ESNext) andfalsebelow. The same source can print4orundefineddepending on this one switch (see the live examples). - 5
Emit options.
outDiris where the output goes;rootDiris the folder that is mirrored inside it. If you havesrc/math.tsandoutDir: "dist", thenrootDir: "src"writesdist/math.js. In TypeScript 6.0 and 7.0rootDirdefaults to the folder of the tsconfig (it used to be "the common parent of all input files"). So withinclude: ["src"]and norootDir, the compiler reportsTS5011and the output lands indist/src/math.js: setrootDir: "src"to say what you mean.noEmitwrites nothing and is for type-check-only runs (Topic 12.5).sourceMap: truewrites.js.mapfiles and a//# sourceMappingURL=comment so debuggers and error stack traces point at your.tslines.declarationwrites.d.tsfiles (Topic 11.3). - 6
**
noEmitOnError** stopstscfrom writing *any* output if there is a type error. By defaulttscwrites JavaScript even when the types are wrong (the types are advice, the JavaScript is still valid), and the exit code tells you which case you were in: 0 clean, 2 errors but output was written, 1 errors and no output. TurnnoEmitOnErroron in a build whose output you ship; leave it off in a dev loop where you want to run code while you fix types. - 7
Options that were removed. TypeScript 6.0 deprecated and 7.0 removed
target: "es5",downlevelIteration,moduleResolution: "node"(also callednode10) and"classic",module: "amd","umd","system",baseUrl,outFile,esModuleInterop: false,allowSyntheticDefaultImports: falseandalwaysStrict: false. A config that uses them gives an error in 6.0 (TS5107/TS5101, which"ignoreDeprecations": "6.0"silences) and a hard error in 7.0 (TS5108/TS5102, which can't be silenced). If you still ship to engines older than ES2015, compile with another tool for that last step (a bundler or Babel) or stay on 6.0 for that project (Topic 12.6).
Explain it without notes
What is the difference between target and lib?
Why can code type-check against lib: ["es2022"] and still crash on an old browser?
What do outDir and rootDir do, and what changed about rootDir in TypeScript 6.0 and 7.0?
What does noEmitOnError change, and what are tsc's exit codes?
Which options were removed in TypeScript 7.0, and what should a project do about them?
Practice
You compile src/app.ts and src/util/log.ts and want the output in build/app.js and build/util/log.js. Write the three tsconfig options you need and say what happens if you forget one.
Your code calls Object.hasOwn(obj, "k") and tsc says the property does not exist. Name the option to change and say what else you must check before shipping to an older browser.
Trade-offs
- ↔
A low
targetreaches more old engines but ships larger, slower code and extra helpers. A hightargetships clean code but needs a runtime (or bundler step) that understands it. - ↔
Emitting on error keeps the dev loop fast (you can run code while fixing types).
noEmitOnErrormakes a release build safe. Many teams use one setting in development and the other in the production build script.
Done when you can
I can explain which things
targetrewrites and which it can't.I can explain why
libis types only, and fix aTS2550error.I can set
outDirandrootDirso the output has the layout I want.I can read
tscexit codes 0, 1 and 2.I know which options 7.0 removed and what to use instead.
I can name one case where
targetchanges behaviour, not only syntax.