tsconfig.json demystified

Breaking down the various options and nuances to better understand what the Typescript compiler does under the hood

Alex Tzinov
Extra Credit-A Tech Blog by Guild
18 min readApr 30, 2020

--

With the rise in popularity of TypeScript as a language, and with its prominence here at Guild, a dive into its inner workings seemed worthwhile.

The goal is simple: To never again blindly flip tsconfig.json options based on disparate Stack Overflow answers without knowing what each option does and why we need it.

Starting with the basics, tsc is the TypeScript compiler itself. It is responsible for taking .ts files and transpiling (transform + compile) them to .js files. While TypeScript gives us types and access to newer language features, the code we write must ultimately be converted to JavaScript that our eventual target (browsers, NodeJS server, etc) can interpret and run. A great starting point in our journey to fully understand tsc and all the possible options is to run: yarn tsc --init

the resulting tsconfig.json after running tsc — init

There’s a lot of options. Some are more important than others, and a lot of them can be left to the default value. We’re going to explore all of them.

📊 To see an analysis of almost 10,000 tsconfig.json files from across GitHub repos and how people are using these options in the wild, see that project here. To help you build up your tsconfig.json files for new projects, see the interactive tsconfig.json generator here.

This is the first article of a multi-part series breaking down in detail each of the tsconfig.json options. They are organized in the same categories as the default tsconfig.json

across a 5 post series. There will be explorations of particular use cases for each option as well as occasional tangents to dive deeper into topics (see these in the footnotes marked by¹ ² ³ etc). Diving into these footnotes is highly recommended if you are less familiar with the concepts as they contain valuable context and background. This article was written in February 2020 (TypeScript version 3.8.3) and while it is my goal to come back and update sections as TS evolves, always reference the TS docs for the latest.

Each option will list the Possible Values for the option (with the default option in bold), a single sentence TL;DR, and a When To Use that will help you decide when you might need the option.

Basic Options

incremental:

Possible Values (true | false)
TL;DR Caches project compilation info to make recompiles faster.
When To Use: When you want to make your TS builds faster.

This tells TypeScript to save information about the projects graph from the previous compilation, serving as a compile cache, using that information to make the subsequent compiles faster. This information is stored in a file called .tsbuildinfo. This is similar to the efficient subsequent re-builds you get with tsc --watch but removes the need to have a running watch command running the whole time. See Microsoft Release Notes for 3.4 for an explanation.

sample, hello world project compiling 2x as fast with — incremental

target:

Params(ES3 | ES5 | ES6(ES2015) | ES7(ES2016) | ES2017 | ES2018 | ES2019 | ES2020 | ESNEXT)
TL;DR What version of JavaScript to transpile your TS code to.
When To Use: When your app needs to run in environments that only support a particular (older) version of JavaScript, you specify that version here.

This is the specific version of JavaScript (ECMAScript) that your TypeScript will be transpiled to. The target you choose for the eventual JS that will run in the browser depends entirely on what browsers your app will support. Modern browsers support all ES6 features, so leaving target at ES6 is acceptable. ES5 would be necessary for supporting IE11, ES3 for supporting IE8, etc. See this compatibility table for better understanding what browsers support what versions.

Example of the same index.ts transpiled with 2 different target versions (ES5 & ES6). Compiling that once more to ES3 would result in the `const` keyword being replaced with `var`

Setting the target tells TypeScript what version of JavaScript to transpile (transform + compile) your code to. Transpilation will take care of converting newer language syntaxes (fat arrow functions, “Classes”, etc) to their equivalent in the older language syntax. TypeScript will not fill in missing functionality that your older targeted browser does not implement natively (eg: Promises, Array.prototype.find, Object.values, etc). For missing APIs that a particular environment does not implement natively, a polyfill¹ is necessary. See the footnotes for a dive into the difference² between polyfills and transpilation.

lib:

Params(ES5|ES6|ES7|ES2017|ES2018|ES2019|ES2020|ESNext|Dom|ScriptHost|WebWorker)
TL;DR The set of language features (in the form of type definitions) you want TypeScript to include and compile (and thus assume will be provided at runtime) so that your code can use (and your IDE understand) these newer features.
When To Use:
When you want to use newer JS features that don’t exist in the version of JS that you’re targeting and need to make a promise to the TS compiler that these features will indeed exist at runtime and to compile your project under that assumption.

This is very closely related to target but is fundamentally different in that it does not change the outputted JS. The lib setting is entirely used to make the TS compiler happy by explicitly telling it which type definitions to include. What lib does is tell TypeScript that certain runtime features will be available, and so it should pull in the respective type definitions files . To see the subtle nuance with lib, let’s run through an example

You need to support IE11 so you set your target to es5. You also want to use Promises. If you simply set target: 'es5' and try to use Promises, this will happen

index.ts:2:14 — error TS2585: ‘Promise’ only refers to a type, but is being used as a value here. Do you need to change your target library? Try changing the `lib` compiler option to es2015 or later.`

Well, you can’t change your target to something that includes Promises like es6because you need to support IE11 and you know that IE11 can only run es5. So you follow the instructions, add es6(es2015)to your --lib option, and run it again

TypeScript compiles successfully, outputted JS has our Promise, everything looks good

Here’s where the lib option can be very misleading. At this point our TS compiler is happy, our outputted code has our desired Promise call, and we have target set to es5 meaning we can support IE11. So let’s open the app in the browser.

Error: 'Promise' is undefined

The lib option does not actually import missing libraries into your JS code. It simply tells the compiler that these libraries “will” be available at runtime. Whether they actually are or not depends on the developer actually importing the missing functionality, often called polyfills¹.

The lib option is a developer promise to the TS compiler saying:
“Don’t complain about me using newer features that my target version doesn’t support, I will ensure the runtime environment will contain implementations for these features.”

lib permits you to specify which versions of ECMAScript you wish to use features from as long as you provide a runtime implementation (polyfill) for them. If you don’t, runtime will blow up.

Another neat way you can see exactly what lib does is to also turn on the listFiles flag which will show you all the files that are part of the compilation. With this you can see the various .d.ts files that will be you included based on your lib setting.

module:

Params: (CommonJS|ES6(ES2015)|ES2020|None|UMD|AMD|System|ESNext)
TL;DR The module system³ that the outputted JS will operate under.
When To Use: Safe to leave as default unless you are targeting browsers and not using a bundler (eg: Webpack) that can understand CommonJS or plan on targeting newer environments and want to use ES6 Modules.

See the footnote³ for a more in-depth exploration of JavaScript modules, but for the majority of use cases this option can be safely left to the default. If you are writing TypeScript for NodeJS, the CommonJS system is fine. If are writing TypeScript for the web, you are most likely using a bundler like Webpack or Rollup which can also understand CommonJS and output JS that the browsers will understand (often by bundling all your transpiled JS into a single file). If you are writing TypeScript for the web but do not plan on using a bundler, then your options are either targeting newer browsers that natively support ES6 modules and setting module to es6 or, setting module to something like AMD or System and including the respective loader (RequireJS, SystemJS) at runtime

allowJs:

Params (true | false)
TL;DR Allows JavaScript files to co-exist in a TypeScript project.
When To Use
When you are incrementally migrating from JS → TS or have legacy JS you don’t want to migrate and want to allow this code to still exist.

checkJs:

Params (true | false)
TL;DR Emit type errors in JavaScript files.
When To Use
When you want to start enforcing stricter types in JS files without fully converting them to TypeScript files.

This flag allows two powerful ways to incrementally type-check JS files.

checkJs:false (in tsconfig.json) +
// @ts-check (at the top of a .js file you want to be type checked)

Enables you to slowly whitelist JS files that you want to have selectively type-checked so you can incrementally type-check a large JS code base making an eventual migration to TS easier

checkJs:true (in tsconfig.json) +
// @ts-ignore (at the top of a .js file you don’t want to be type checked)

Enables you to blacklist JS files that you don’t want to be type-checked. Useful if the majority of your JS code base passes type checks but you have a few larger, problematic files

jsx:

Params (preserve | react | react-native)
TL;DR What to do with jsx⁴ (<button/>, <MyComponent/>, etc) in your code
When To Use
When you don’t want TypeScript managing your JSX and want tsc to leave it untouched in the transpiled JS or when you want to support React Native, otherwise leave default

This option allows you to decide what the compiler does with your tsx/jsx. As part of the conversion process of going from TypeScript that is easy and understandable to write to JS that the browser can interpret and run, we must take our jsx syntax which is what allows us to neatly organize components like this:

and convert it to pure JavaScript code that the browser can understand (in this particular case using React.createElement for the jsxFactory):

The jsx option allows you to dictate if this conversion takes place. The default is react where every element gets converted according to the render function specified with jsxFactory. This is actually rather confusing behavior / option naming which took some trial and error to understand. Setting jsx to react does not necessarily mean that React’s React.createElement will be used as the JS equivalent of the jsx syntax. What it means is that TypeScript will indeed convert JSX to JavaScript, but will do so according to the rendering function specified in jsxFactory (which defaults to React.createElement) . preserve will leave your JSX as is, allowing you to include a later (and necessary) build step that will be responsible for transpiling the JSX (eg: Babel).

declaration:

Params (true | false)
TL;DR Generate type definition files for your source to be imported elsewhere
When To Use
When you are writing a library and want to be a courteous member of the TypeScript community and provide consumers of your library with types to improve their developer experience and help make their code safer.

The purpose of a .d.ts file (declaration file) is to preserve the typings of your lib when you export your transpiled JS code.

example of what gets generated when using the — declaration flag

To preserve the typings that were originally there (and then stripped as part of the build process) so the consumer of your library can get valid TypeScript checks, you need to also export a .d.ts file alongside your .js file. When the consumer imports your lib, they import the functionality from the .js file and the type checks from the .d.ts file.

The reason exporting types alongside your JS code is important is that it helps the consumer of your library prevent potential runtime errors when using your library. If your library exports a function that takes a string and calls toUpperCase on it, and you have failed to provide types, a consumer might try and pass in a number which will lead to a runtime error. If you provide types, their TypeScript will fail to compile until they pass in a parameter of the correct type, saving them a runtime error.

declarationMap:

Params (true | false)
TL;DR Generate mappings to tie definition files (.d.ts) to source code (.ts)
When To Use
When you want to further improve the developer experience of the consumer of your library by letting them jump not just to your libraries types but to their implementation as well.

Generates a source map for .d.ts files which map back to the original .ts source file. This will allow editors such as VS Code to go to the original .ts file when using features like Go to Definition. What this allows is for consumers of a library to have an exported .d.ts file and then from that map to the original TS source code when clicking through imported components and not just the .d.ts file.

Let’s add to the example introduced above where your library exposes a function that takes a string and calls toUpperCase on it. By enabling declarationMap, not only does the consumer know that your function takes in a string (which they might choose to ignore for whatever reason and cast their number to a “string” to fool the compiler), they can now jump to the implementation in their IDE and see exactly what your function will be doing with the parameter they pass in (and hopefully discourage them to abuse the type system when they can see why a string needs to be passed in).

sourceMap:

Params (true | false)
TL;DR Generate mappings to tie outputted code (.js) to source code (.ts)
When To Use
When you want to make debugging your app in the browser much easier

This creates sourceMaps from the compiled JS files back to the original TS source files allowing for easier debugging in the browser. You can place breakpoints in the same TypeScript code that you wrote, not some minified/compiled JavaScript code. This can improve the debugging experience tremendously if your code undergoes a heavy transformation from TS to JS. This often happens if you use modern features (async/await , Object destructuring, etc) and are targeting an older browser and transpiling to ES3 or ES5.

outfile:

Params (<path>)
TL;DR Combines all amd or system modules into a single file
When To Use
When you are using system or amd modules and want to concatenate all modules into a singe file

Concatenates all the source code into a single output, JS file.

outDir:

Params (<path>)
TL;DR Where should outputted/transpiled files go
When To Use:
When you want to dictate where outputted files go and not leave the default value which is in the same directory as the source code

If specified, files will be emitted to this directory. Otherwise they will be emitted in the same directory as the source files. If both outDir and outFile options are specified, the latter takes precedence and the outDir option is ignored.

composite:

Params (true | false)
TL;DR Allows a TypeScript project (or sub-directory that is to be treated as a separate TS project) to be referenced from a separate TS project
When To Use
When you have nested TypeScript projects that depend on each other as part of their build process, often used when you want to break apart a large TS project into smaller sub-projects to improve compile times

“The composite field ensures certain options are enabled so that this project can be referenced and built incrementally for any project that depends on it”

This StackOverflow explanation is one of the best out there on this rather obscure but in the right times useful option https://stackoverflow.com/questions/51631786/how-to-use-project-references-in-typescript-3-0

tsBuildInfoFile:

Params (<path>)
TL;DR The name of the file where the project compile cache information will be saved to. See incremental option talked about at the top
When To Use
When you want to change the default behavior of saving to .tsbuildinfo

removeComment:

Params (true | false)
TL;DR Strips comments from source code and does not include them in transpiled JS
When To Use
When you want to remove sensitive comments or reduce bundle size

noEmit:

Params (true | false)
TL;DR Do not emit any compiled JS or declaration files
When To Use
When you want to use the TypeScript compiler for type-checking and IDE support only and would like a later step (Babel/Webpack) to take care of the actual transpilation

This allows for other tools like Babel or Webpack to take care of generating the actual JS and leaves only type checking and IDE support to Typescript. Used when TypeScript is used for type checking and not compilation. create-react-app which uses webpack under the hood has noEmit set to true by default

importHelpers:

Params (true | false)
TL;DR Will replace inline helper function implementations for things like async/await and Object spread operator {...obj} that are otherwise redefined and injected into each file with the respective function call from the centralized tslib library
When To Use
When you want to reduce your file size by making your outputted JS more DRY and have helpers that are otherwise injected into each file come from a centralized lib

If you inspect JS code that is transpiled, you will notice that a lot of helper functions that implement/back-port some of the newer syntax we get in newer versions of JS like async/await and the Object spread operator. Since these are language features, they can be transpiled without importing polyfills. However, transpiling often leads to incredibly verbose code since the newer syntax’s functionality must be recreated with older versions of the language. In fact, the following code:

which is just 9 lines of awaiting a Promise to resolve and cloning properties of an object using the spread operator, results in a 65 line ES5 mess of transpiled JS. Among this mess are helper functions like __assign , __awaiter , and __generator. By default these helper functions get inlined at the top of every file that uses newer syntax that needs to be transpiled. If you have 10 similar files like the one above (very likely since syntax features make your life easier and thus are often used across an entire code base), you will have over 650 lines of repeated helper functions injected over and over.

What importHelpers does is tell TS, “Hey don’t bother inlining those, import them from tslib once”. If this setting is enabled but TypeScript cannot find the tslib package in your project, you will get an error. You must also enable noEmitHelpers to prevent TS from inlining these helpers across your files.

See the tslib package for more information as well as https://mariusschulz.com/blog/downlevel-iteration-for-es3-es5-in-typescript

downlevelIteration:

Params (true | false)
TL;DR Include a more sophisticated and comprehensive implementation for syntax like for item of collection that will work under all use cases in ES3 and ES5
When To Use
When you want to ensure that iterables are transpiled to a correct implementation in ES3/ES5

A very thorough and easy to understand explanation of this flag can be found here but the synopsis is to turn on this flag if you want to ensure you have your bases covered when using the for .. of syntax in older browsers that without this flag have an inaccurate implementation under certain edge cases

isolatedModules:

Params (true | false)
TL;DR Enables stricter checks to ensure your outputted code can be handled by single-file interpreters like Babel and does not change your outputted code or the type checking process
When To Use
When you are using another build system like Babel and want TypeScript to warn you if you write certain code that can’t be correctly interpreted

Any feedback is welcome to help ensure the accuracy and phrasing of these descriptions. Would love to hear any alternate ways to describe some of
the options that people come up with. In the spirit of:

Everything should be made as simple as possible, but not simpler.”

brevity and approachability are the ultimate goals of this series.

Footnotes

[1] What is a polyfill?
A polyfill is a piece of code (usually JavaScript on the Web) used to provide modern functionality on older browsers that do not natively support it.” It is code that is added as part of your application to cover functionality that you expect the browser to implement natively. IE11 does not support Promises natively, so a polyfill (Axios, Bluebird, etc — a library that can be imported that will fill in the missing Promise implementation) is needed. This article (by the person that coined the term) provides an explanation.

[2] Polyfill vs. Transpile
What’s the difference between a polyfill and what TypeScript does which is transpile (transform + compile)? A polyfill inserts a missing implementation that the original code was attempting to invoke. Without the polyfill, new Promise() is undefined. With the imported polyfill (any library that implements the full spec of Promises), new Promise() will be defined and will call into the imported implementation. Transpiling will convert the existing code (which often includes newer, more elegant syntax) into the equivalent logic using an older version of the language. New syntax features can’t be polyfilled — older JS engines will simply not understand the newer code.

Transpiling means converting every () => {} to function(){} , every const /let to var, etc throughout the entire code base. You are converting from one language (ES6) to another (ES3).
Polyfilling means implementing an API that you use throughout your app if it doesn’t already exist.
if (!Object.entries) Object.entries = implementationProvidedExternally

[3] Modules
JavaScript was never really built with modules in mind. It started out as something where you imported individual snippets into webpages by using <script> tags. A script for an animation. A script for a date-time converter. Script to store user data from an <input> field and then do something with it. Small things. As the complexity of these imported scripts grew, the need to order them became more important since they had dependencies that had to get imported first. Also, as the amount of JS grew, you had a higher chance of variable collision since every variable was put into a global scope (window). Any JS injected to an HTML page could have access to any of the variables introduced by other scripts. There arose a need for “modules” — a way to separate concerns, manage dependencies, and isolate scopes away from the global window object

Different solutions for managing these modules came about in the form of “specs” for writing modules. There was the CommonJS module system mainly used for server-side code. There’s AMD which is the system/spec that RequireJS implements and is used for browsers (the way it loads modules is more conducive for the async demands of how a browser works). Nowadays, there’s something called ES6 modules which are the latest and greatest and preferable way to go forward.

What is rather standard is most other languages — the concept of code living in separate modules and packages where it is encapsulated and easily exportable/importable — was never part of the original specification for JS. Therefore, competing methods have risen and fallen over the years with varying amounts of support. Today, ES6 modules are the forefront of becoming a standardized approach

[4] JSX
JSX — the bread and butter of React component syntax — is what allows us to organize and write our components in the XML/HTML-tag like syntax that we are used to from pure HTML. It allows us to “mix” JavaScript with what looks like pure HTML by writing our components like this

What JSX does is “produce” or create React elements. We can see this “creation” of React Elements by using either the Babel Playground or the TypeScript Playground and pasting the exact code from above and seeing what the JSX gets transpiled to. The outputted JS contains a bunch of React.createElement calls, which now the browser can understand and evaluate (assuming you’ve included the react package of course). JSX isn’t necessary to write React code — you can construct your entire UI using a bunch of React.createElement calls — but it makes it much easier to write, reason about, and maintain your React components.

There are other libraries that can handle jsx and convert it to the respective JS calls. Preact and Snabbdom are 2 examples of React alternatives that also support jsx, each making the case to use something besides React (package size, DOM performance, etc)

References

--

--