Understanding tsconfig.json
By Flavio Copes
Understand tsconfig.json options that matter: strict, target, lib, module resolution, noEmit, paths, include, and a modern starter configuration.
tsconfig.json defines a TypeScript project.
It tells the compiler which files belong to the project, how to resolve imports, which JavaScript features exist, how strict type checking should be, and whether TypeScript should write output files.
Without a config file, tsc uses defaults. Those defaults cannot know whether we are building a browser app with Vite, a Node.js service, or a library.
The right config starts with the runtime and build tool.
Create a config
Install the latest TypeScript 6.x release in the project:
npm install -D typescript@6
Then create a starting file:
npx tsc --init
The generated config includes comments and many options. You do not need to understand all of them at once.
I prefer a short config where every option has a reason to exist.
If TypeScript itself is new to you, start with the free TypeScript course before tuning compiler flags.
The shape of tsconfig.json
Most settings live under compilerOptions:
{
"compilerOptions": {
"strict": true,
"noEmit": true
},
"include": ["src"]
}
compilerOptions controls checking, module handling, and output.
include, exclude, and files decide where the project begins looking for source files.
extends lets one config inherit another.
Turn on strict checking
Start with this:
{
"compilerOptions": {
"strict": true
}
}
strict enables a family of checks, including strictNullChecks, noImplicitAny, and strictFunctionTypes.
Here is the difference strictNullChecks makes:
const user = users.find(user => user.id === requestedId)
console.log(user.name)
find() can return undefined. Strict checking makes us handle that case:
const user = users.find(user => user.id === requestedId)
if (!user) {
throw new Error('User not found')
}
console.log(user.name)
My advice is to enable strict when the project starts. Turning it on after years of loose code creates a large migration instead of a daily safety net.
Add stricter indexed access when it helps
strict does not enable every safety option.
Consider an array lookup:
const names = ['Jack', 'Syd']
const first = names[0]
By default, first is typed as string, even though an index can be missing.
Enable noUncheckedIndexedAccess to include undefined:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
This also affects index signatures such as Record<string, User>. It catches real mistakes, but it adds checks in code that already controls the keys.
I like it in new applications. I introduce it separately in an existing project because it can produce many errors at once.
Decide what optional means
An optional property can be absent:
type User = {
nickname?: string
}
Without exactOptionalPropertyTypes, assigning undefined is generally allowed:
const user: User = {
nickname: undefined,
}
With this option:
{
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}
“missing” and “present with the value undefined” stay distinct unless the property type explicitly includes undefined.
This is useful when object keys have runtime meaning, such as patch requests or configuration merging.
target controls emitted JavaScript
target tells TypeScript which JavaScript syntax it may leave in emitted files.
{
"compilerOptions": {
"target": "ES2022"
}
}
An older target makes TypeScript rewrite more syntax. A modern target keeps more of the source intact.
Choose the oldest runtime you actually support. Do not lower the target from habit.
If noEmit is enabled, another tool writes the JavaScript. target still influences type checking and the default library definitions.
lib controls available platform APIs
lib tells TypeScript which built-in APIs exist in the target environment.
A browser application might use:
{
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
ES2022 provides JavaScript language APIs. DOM provides browser globals such as document, fetch, and HTMLElement.
A Node.js service should not add DOM only to make a missing global error disappear. Install the Node types and configure the project for its real runtime.
target and lib answer related but different questions:
targetcontrols JavaScript syntax in emitted codelibcontrols which platform APIs TypeScript knows
Neither one adds a polyfill. If an old browser lacks an API, a type definition does not make it exist at runtime.
module and moduleResolution must match the toolchain
module controls how TypeScript treats and emits imports and exports.
moduleResolution controls how TypeScript finds the files behind those imports.
For an application bundled by Vite or another modern bundler, use bundler resolution:
{
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler"
}
}
preserve keeps each import and export form for the bundler. bundler understands package exports and does not require JavaScript file extensions on relative TypeScript imports.
For a modern Node.js project that TypeScript emits directly, use the Node pair:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
Node’s result also depends on file extensions and the nearest package.json type field.
Do not copy a browser bundler config into a Node service. Import rules should match the runtime that will execute the files.
Use noEmit when another tool builds the code
Vite, esbuild, Astro, Next.js, and similar tools usually handle JavaScript output.
In that setup, TypeScript only needs to check types:
{
"compilerOptions": {
"noEmit": true
}
}
Run the check with:
npx tsc --noEmit
The command exits with an error when type checking fails and writes no .js files.
For a Node.js service or library emitted by tsc, omit noEmit and configure output instead:
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true
}
}
declaration is useful for a published library. An internal application normally does not need .d.ts output.
Use isolatedModules with a per-file compiler
Bundlers transform one file at a time. They do not have the same whole-program information as tsc.
This option warns about TypeScript patterns that cannot be transformed safely in isolation:
{
"compilerOptions": {
"isolatedModules": true
}
}
It does not perform the transform. It checks that each file can be handled by a tool that works independently.
Keep imports explicit with verbatimModuleSyntax
Types disappear at runtime. Values do not.
verbatimModuleSyntax keeps import behavior predictable and asks us to mark type-only imports:
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
Use import type for a type:
import type { User } from './user'
import { loadUser } from './user'
The first import can be removed from the JavaScript output. The second must remain because the program calls it at runtime.
This option works well in modern ESM projects. Check a framework’s recommended config before changing an established project.
esModuleInterop helps with CommonJS packages
Some packages use CommonJS exports while our source uses ESM imports.
esModuleInterop adds compatibility helpers when TypeScript emits modules and changes how some default imports are checked:
{
"compilerOptions": {
"esModuleInterop": true
}
}
Many framework templates enable it. In a fully modern ESM project it may not affect much, but older CommonJS dependencies can make the option useful.
Do not treat it as a general fix for every import error. First check the package’s export format and the project’s module settings.
Understand skipLibCheck
skipLibCheck skips type checking inside declaration files, including many files under node_modules.
{
"compilerOptions": {
"skipLibCheck": true
}
}
This can make application checks faster and avoid errors caused by conflicting dependency declarations.
It does not skip checking how your code uses those declarations.
The tradeoff is that broken or duplicated library types can stay hidden. I often enable it in an application. I am more cautious in a library whose job is to publish types to other projects.
include, exclude, and files
include defines the starting set of project files:
{
"include": ["src", "vite.config.ts"]
}
Patterns are relative to the config file.
exclude removes matches from what include finds:
{
"exclude": ["dist", "coverage"]
}
Be careful: exclude is not a firewall. If an included file imports an excluded file, TypeScript can still add that dependency to the project.
Use files when you want an explicit list of entry files:
{
"files": ["src/index.ts"]
}
Most applications use include. An exact files list is more common in small configs and carefully structured packages.
paths creates TypeScript aliases
Aliases can replace long relative imports:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
Now TypeScript can resolve this:
import { Button } from '@/components/Button'
But paths does not rewrite the import in emitted JavaScript. Your bundler or runtime needs the same alias.
This is a common mistake: the editor accepts the path, then the production process cannot load it.
Configure the alias in the build tool too, or use a framework feature that reads tsconfig.json paths for you.
types limits global type packages
By default, visible @types packages can add global names to the project.
Use types when you want an explicit list:
{
"compilerOptions": {
"types": ["node"]
}
}
This does not stop you importing other typed packages. It controls which packages contribute global declarations.
It is useful when test frameworks or multiple runtimes add globals with the same names.
Share settings with extends
Large repositories often need more than one config.
Put shared checks in a base file:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
Then extend it:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"]
}
Keep runtime-specific options in the child config. A browser app and Node.js script can share strictness without pretending they have the same globals or module rules.
A starter config for a bundled browser app
Here is the config I would start with for a modern browser application using a bundler:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
This is a starting point, not a universal answer.
I would remove paths if the project does not need aliases. I would also use the framework’s generated config when it provides one, then add strictness deliberately.
Check which config TypeScript sees
Show the final config after extends and defaults are applied:
npx tsc --showConfig
This is useful when an option seems to have no effect.
To check a specific project file, use -p:
npx tsc -p tsconfig.json --noEmit
Do not pass source filenames when you expect tsconfig.json to apply:
npx tsc src/index.ts
When input files are listed on the command line, TypeScript ignores the project config and uses command-line options instead.
That small detail explains many “the option is enabled but nothing changed” problems.
How I tune a config
I begin with the runtime: browser, Node.js, or both. Then I identify who emits JavaScript: tsc or another build tool.
After that I enable strict, choose the matching module strategy, and keep the file list small. Extra safety flags come next.
I do not copy a giant config and hope every option helps. A short config is easier to review, easier to upgrade, and easier to debug.
The language side matters too. Type aliases and interfaces explain how to model your application once the compiler is configured.
Want me to talk about your product? You can sponsor this site.
Related posts about typescript: