tsconfig.json demystified
Breaking down the various options and nuances to better understand what the Typescript compiler does under the hood
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
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
- Basic Options (this article)
- Strict Type-Checking Options and Additional Checks
- Module Resolution Options
- Source Map Options (Coming Soon)
- Advanced Options (Coming Soon)
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.
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.
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 target
ing 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 es6
because 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
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:
<MyComponent>
<MyComponentsChildren>
<button title='some button' onClick={alert}/>
</MyComponent>
and convert it to pure JavaScript code that the browser can understand (in this particular case using React.createElement
for the jsxFactory
):
React.createElement(MyComponent, null,
React.createElement(MyComponentsChildren, null,
React.createElement("button",
{ title: 'title', onClick: alert}
)
)
);
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.
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 await
ing 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
const HeaderText = <h1>This is a Header</h1>
const HeaderElement = <header>{HeaderText}</header>
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)