Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~29 min· topic 3 of 6

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 ....)

src/main.tswhole filets
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.

dist/main.js (target es2015, trimmed)whole filets
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.

dist/main.js (target es2017, the function only)whole filets
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.

dist/main.js (target es2022)whole filets
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);
target es2022: your source, types removeddiagram
Rendering diagram…

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.

terminalwhole filebash
$ 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);        // undefined

06lib: 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.

terminalwhole filebash
# "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.

terminalwhole filebash
# 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.

terminalwhole filebash
$ 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 directory

09es5 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.

terminalwhole filebash
# 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, unchanged

Try it yourself

  1. 1

    Break the lib

    The live example above uses the default libraries. In a real project, set "lib": ["es2020"] and run tsc: predict the errors before you read the terminal step. You'll lose at (ES2022) and, because lib replaces the default list, console and document too.

    Then change the list to ["es2022", "dom"] and the file is clean. In the browser example here, you can check the at result yourself by replacing scores.at(-1) with scores[scores.length - 1]: the type becomes number | undefined too, because of noUncheckedIndexedAccess.

  2. 2

    Fix the field properly

    In the second example, change sides!: number; to declare sides: number;. The warning goes away and the console prints 4: declare says "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. 3

    Pick the exit code

    Take any project with a deliberate type error and run tsc; echo $? twice: with and without noEmitOnError. Before running it, predict both exit codes and whether dist/ exists. (2 and yes, then 1 and no.)

Code & diagrams

lib in action: the checker already knows ES2022 and the browser

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 true
A redeclared field: the default semantics of an ES2022+ target

This 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

undefined
The same class with useDefineForClassFields: false

This 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

4

Break 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.

terminal
$ tsc
── what you'll see ──
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.

Break #2

Keep target es5 in an old config

Run TypeScript 7.0.2 on a tsconfig that still says "target": "es5".

terminal
$ tsc
── what you'll see ──
tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.

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 __awaiter are emitted into every file that needs them unless you set importHelpers and install tslib. Large downlevelled projects ship megabytes of repeated helpers; in modern targets the problem mostly disappears, which is another reason to raise target.

  • ▸

    Bundlers (esbuild, Vite, SWC) take their own target and do the syntax lowering themselves, so with them the tsc target mostly decides what the checker assumes and how class fields behave, not the final output. Keep the two settings in agreement.

  • ▸

    lib also holds the version-specific pieces that target is not responsible for, like lib.esnext.disposable (the types for using, TypeScript 5.2) or, before 6.0, lib.dom.iterable (iterating a NodeList; 6.0 folded it into lib.dom and 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 always lib, not target.

  • ▸

    The .js.map file's sources path is relative to the output file, so moving dist to another folder without moving the source (or serving .ts in production) breaks debugging silently. Set sourceRoot or inlineSources when you deploy maps separately.

Remember this

  1. 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, lib and the emit options settle this. **target** is the version of JavaScript that tsc writes. Syntax newer than the target is downleveled: rewritten into older, longer code that behaves the same. With target: "es2015" the arrow functions stay, but async/await becomes a generator wrapped in a helper, ?. and ?? become long === null || === void 0 checks, ** becomes Math.pow, and #private fields become WeakMap helpers. With target: "es2022" almost nothing is rewritten. Set target to the oldest engine you really must run on, and no older. Since TypeScript 6.0 (and in 7.0) the default target is the newest stable ECMAScript version, not the old ES5.

  2. 2

    **lib is the set of type declarations for built-in objects**: Array, Promise, Map, document, console. target picks a default lib (the standard library of that ECMAScript version, plus the DOM and related browser APIs when you don't list one). If you write lib yourself, you replace the default entirely. Crucially, lib is only types: it never adds code. [1, 2].at(-1) needs lib to include ES2022; and even when the checker is happy, the runtime must have Array.prototype.at. Making old runtimes have it is the job of a polyfill (a small script that defines the missing function), not of tsc.

  3. 3

    This is the difference between the two kinds of "new": syntax (async, ?., classes, #x) can be rewritten by the compiler, so target handles 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: lib decides whether the checker accepts the name, and you decide whether the runtime has it. The error for a missing one is TS2550: Property 'at' does not exist on type 'number[]'. Do you need to change your target library?.

  4. 4

    target also changes meaning, not just spelling. Class fields are the famous case. Since ES2022, sides!: number in a subclass really defines a field that is set to undefined after super() runs. The old TypeScript behaviour just wrote nothing. The option **useDefineForClassFields** picks which one you get, and its default is true when the target is ES2022 or newer (including ESNext) and false below. The same source can print 4 or undefined depending on this one switch (see the live examples).

  5. 5

    Emit options. outDir is where the output goes; rootDir is the folder that is mirrored inside it. If you have src/math.ts and outDir: "dist", then rootDir: "src" writes dist/math.js. In TypeScript 6.0 and 7.0 rootDir defaults to the folder of the tsconfig (it used to be "the common parent of all input files"). So with include: ["src"] and no rootDir, the compiler reports TS5011 and the output lands in dist/src/math.js: set rootDir: "src" to say what you mean. noEmit writes nothing and is for type-check-only runs (Topic 12.5). sourceMap: true writes .js.map files and a //# sourceMappingURL= comment so debuggers and error stack traces point at your .ts lines. declaration writes .d.ts files (Topic 11.3).

  6. 6

    **noEmitOnError** stops tsc from writing *any* output if there is a type error. By default tsc writes 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. Turn noEmitOnError on 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. 7

    Options that were removed. TypeScript 6.0 deprecated and 7.0 removed target: "es5", downlevelIteration, moduleResolution: "node" (also called node10) and "classic", module: "amd", "umd", "system", baseUrl, outFile, esModuleInterop: false, allowSyntheticDefaultImports: false and alwaysStrict: 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

01

What is the difference between target and lib?

02

Why can code type-check against lib: ["es2022"] and still crash on an old browser?

03

What do outDir and rootDir do, and what changed about rootDir in TypeScript 6.0 and 7.0?

04

What does noEmitOnError change, and what are tsc's exit codes?

05

Which options were removed in TypeScript 7.0, and what should a project do about them?

Practice

01

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.

02

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 target reaches more old engines but ships larger, slower code and extra helpers. A high target ships 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). noEmitOnError makes 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 target rewrites and which it can't.

  • I can explain why lib is types only, and fix a TS2550 error.

  • I can set outDir and rootDir so the output has the layout I want.

  • I can read tsc exit codes 0, 1 and 2.

  • I know which options 7.0 removed and what to use instead.

  • I can name one case where target changes behaviour, not only syntax.