Right, let’s talk about target. This is where you tell the TypeScript compiler what version of JavaScript it should aim for. It’s not a suggestion; it’s a hard constraint. Think of it less like choosing a target on a shooting range and more like telling your time-traveling car what year you want to emerge in. Set it too far back, and you’ll be writing code that has to avoid all the cool features the future invented. Set it too far forward, and you’ll crash into a browser that’s still waiting for the invention of the wheel.

The compiler’s job is to take your beautiful, modern TypeScript (or JavaScript) and make it run in the environments you specify. If you use a feature in your source code that isn’t natively supported by the JavaScript version you’re targeting, TypeScript will downlevel it. It will rewrite that fancy async function into a tangled mess of callbacks and generator functions that would make a 2015 developer feel right at home. It’s ugly, but it works.

The available options are ES3, ES5, ES6/ES2015, ES2016, ES2017, up to the latest ESNext. My advice? Unless you’re supporting a legacy codebase that must run on a toaster connected to Internet Explorer 11, you should almost never go below ES2015. That’s the release that gave us let, const, arrow functions, and Promises—the absolute bedrock of modern JS.

The Compiler’s Transformation Layer

So, what does this transformation actually look like? Let’s say you write a lovely piece of modern code:

// This is what you write
const greet = (name: string) => `Hello, ${name}`;
const p = Promise.resolve("World");

Now, watch what happens when we change the target.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES5",
  }
}

Compiling the above code with target: "ES5" forces TypeScript to rewrite it into something every browser since the stone age understands:

// This is the ugly (but functional) output
"use strict";
var greet = function (name) { return "Hello, " + name; };
var p = Promise.resolve("World");

See what happened? The const and arrow function are gone, replaced by var and a traditional function expression. But notice: the Promise is still there. Why wasn’t it downleveled? Because target only controls the transformation of syntax. It doesn’t handle polyfilling APIs like Promise, Map, Set, or Object.assign. That’s a job for a different tool, like core-js, and a different compiler option called lib, which we’ll get to later. This is a classic “gotcha.”

Why You Should Probably Set target: "ES2020" (or higher)

For any new project in 2024, setting your target to ES2020 or ES2022 is a no-brainer. Here’s why: it keeps the output clean and readable. Let’s take a nullish coalescing operator (??), an ES2020 feature.

// Your source
const title = document.title ?? "Default Title";

With target: "ES2020", the output is identical. The compiler doesn’t touch it because every modern environment understands it.

// Output with target: "ES2020"
const title = document.title ?? "Default Title";

Now, compile that with target: "ES2015":

// Output with target: "ES2015"
var title = (_a = document.title) !== null && _a !== void 0 ? _a : "Default Title";

Yikes. That’s a verbose, generated ternary check for null and undefined (hence void 0). It’s correct, but it’s a nightmare to debug through. By setting a modern target, you’re ensuring your compiled output isn’t a grotesque funhouse mirror of your original logic. It’s easier to debug, and often, slightly more performant because the native engine implementation of a feature is almost always faster than the compiler’s emulated version.

The Critical Relationship with lib

I mentioned this earlier, but it’s so important it deserves its own header. target and lib are siblings, and you must understand their relationship. The lib setting tells the compiler what ambient type definitions are available—what JavaScript APIs (like Promise, fetch, document) it can assume exist in your runtime environment.

By default, lib is chosen based on your target. If you set target: "ES5", the default lib includes the types for ES5 and, crucially, the DOM. This is why our Promise example earlier compiled without error; the compiler knew about the Promise type from the DOM library, even though it wouldn’t actually exist in a pure ES5 environment.

This is a potential pitfall. Your code might type-check perfectly because the compiler knows about modern APIs, but then it crashes at runtime in an old browser because those APIs aren’t actually there. The solution is to either:

  1. Set a lib that matches your actual runtime (e.g., if you’re targeting real ES5 browsers, you might set "lib": ["ES5"] and then you’d get a type error on Promise), or
  2. Use a polyfill library like core-js to provide those missing APIs at runtime.

For most web projects, the default behavior (letting lib be inferred from target) is fine, as you’re almost certainly using a polyfilling bundler like Webpack or Vite. But you need to be aware of the distinction.

The esnext Wildcard

You can set "target": "ESNEXT". This is TypeScript’s way of saying “target the very latest proposed ECMAScript features.” It’s the bleeding edge. This is useful if you’re writing code for a known, ultra-modern environment (like the latest Node.js or a specific browser) and you want to use features the second they are stable in TypeScript. It’s generally not recommended for broad distribution, as you might end up outputting syntax that isn’t finalized or widely supported yet. Use it with intention, not by default.