TL;DR
Learn tsconfig.json to configure your TypeScript project. Master strict mode, compiler targets, path aliases, and essential flags.
Key concepts
- tsconfig.json
- TypeScript configuration
- TypeScript strict mode
- TypeScript compiler options
Config And Tsconfig
TypeScript is not just a language — it is a configurable type system. The same source code can be checked at wildly different levels of strictness depending on how you configure it. You can permit implicit any, allow null to flow everywhere, or lock everything down so tightly that the compiler catches every possible mistake before you run a single line.
That configuration lives in tsconfig.json, a file at the root of your project that controls everything from how strictly TypeScript checks your code to which JavaScript version it compiles down to. Understanding this file is not optional — it is the difference between TypeScript feeling like a helpful assistant and feeling like a bureaucratic obstacle.
This lesson covers the options that matter most: strict mode, null safety, implicit any, and compiler targets. By the end, you will know how to read a tsconfig, explain what each flag does, and write code that works correctly under strict settings.
What tsconfig.json Does
When you run tsc or open a TypeScript project in your editor, the compiler reads tsconfig.json to know two things: which files to include, and how to check and compile them. A minimal config looks like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}
The compilerOptions block is where the type-checking and compilation behavior lives. The include array tells the compiler which files belong to the project. Most of your time will be spent in compilerOptions.
Strict Mode and Null Safety
The single most important flag is "strict": true. It is not one option — it is a bundle of several strictness checks switched on together. The most impactful member of that bundle is strictNullChecks.
With strictNullChecks enabled, null and undefined are not secretly assignable to every type. They are their own distinct types. This forces you to handle missing values explicitly, which eliminates an entire class of runtime errors.
// With strictNullChecks enabled, you must handle null explicitly.
// TypeScript tracks whether a value can be null through every branch.
function getDisplayName(name: string | null): string {
// TypeScript requires you to narrow before using 'name' as a string
if (name === null) {
return "Anonymous";
}
// After the check, TypeScript knows 'name' is string, not null
return name.trim();
}
console.log(getDisplayName(" Alice ")); // "Alice"
console.log(getDisplayName(null)); // "Anonymous"
// The nullish coalescing operator is a clean alternative for simple defaults
function getPort(port: number | null | undefined): number {
return port ?? 3000;
}
console.log(getPort(8080)); // 8080
console.log(getPort(null)); // 3000
console.log(getPort(undefined)); // 3000
Notice that name.trim() is only valid after the null check. TypeScript narrows the type inside the if branch and only permits string methods once it knows the value is not null. This is strict mode doing its job.
The Predict below makes the flag concrete: the SAME code type-checks or not depending on strictNullChecks. Under strict mode, the error reads exactly:
error TS18047: 's' is possibly 'null'.
Predict
The function reads s.length with no null check, and s is typed string | null. This exact code is checked TWICE: once with strictNullChecks: true, once with strictNullChecks: false. Which row below is right — and separately, what does the tsx runner do with len('hi') (types stripped)?
function len(s: string | null): number {
return s.length; // no null check
}
console.log(len("hi"));Preventing Implicit Any
The second major flag bundled into strict is noImplicitAny. Without it, TypeScript silently infers any whenever it cannot determine a type — most commonly for unannotated function parameters. The result is type-checking that quietly stops working in exactly the places you most need it.
// noImplicitAny forces explicit annotations on parameters.
// This is a good thing — it makes function contracts clear.
// This would be an error under noImplicitAny:
// function double(n) { return n * 2; } // Parameter 'n' implicitly has an 'any' type
// Correct pattern — annotate your parameters:
function double(n: number): number {
return n * 2;
}
// For flexible functions, use generics instead of any:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function mapValues<T, U>(arr: T[], transform: (item: T) => U): U[] {
return arr.map(transform);
}
console.log(double(21)); // 42
console.log(first([10, 20, 30])); // 10
console.log(first<string>([])); // undefined
console.log(mapValues([1, 2, 3], n => n * n)); // [1, 4, 9]
console.log(mapValues(["a", "b"], s => s.toUpperCase())); // ["A", "B"]
Generics give you the flexibility of any with none of the unsafety. The return type of first is correctly inferred as T | undefined, and mapValues enforces that your transform function matches the array element type.
Recall
Without scrolling up: when noImplicitAny forbids a silent any on a flexible function, this section reaches for generics instead — first<T>(arr: T[]): T | undefined. You learned generics in full back in *Generics*. Why is a generic the RIGHT replacement for any here, rather than a loss of flexibility?
Compiler Targets
The target option controls which JavaScript version TypeScript emits. Your source can always use modern TypeScript syntax — target only affects the output. Setting "target": "ES5" means async/await compiles down to promise chains and helper functions. Setting "target": "ES2017" or higher means the output keeps async/await syntax intact, because async/await is an ES2017 feature.
// Regardless of your 'target', you write modern TypeScript.
// The compiler handles the downleveling when needed.
// Async/await, optional chaining, and nullish coalescing
// all work in TypeScript source regardless of target.
interface Product {
id: number;
name: string;
price: number;
category?: string;
}
function formatProduct(product: Product): string {
const category = product.category ?? "Uncategorized";
return `[${category}] ${product.name} — $${product.price.toFixed(2)}`;
}
const products: Product[] = [
{ id: 1, name: "Keyboard", price: 79.99, category: "Hardware" },
{ id: 2, name: "Mouse Pad", price: 14.5 },
{ id: 3, name: "Monitor", price: 349.0, category: "Hardware" },
];
products.forEach(p => console.log(formatProduct(p)));
// Array methods introduced in newer targets (e.g. Array.at) are
// controlled by the 'lib' option, separate from 'target'
const last = products.at(-1);
console.log(`Last product: ${last?.name}`);
A common pitfall: target controls syntax, but lib controls which built-in APIs are available. If you set target: "ES5" but want to use Array.prototype.find, you need to add "lib": ["ES2015"] to make the types available.
Essential Options at a Glance
Beyond strict, several options appear in almost every production tsconfig:
| Option | Purpose |
|---|---|
strict | Enables all strictness checks at once |
target | Sets the output JavaScript version |
module | Controls how imports/exports compile (CommonJS, ESNext) |
lib | Built-in type definitions to include (DOM, ES2022) |
outDir | Where compiled .js files are written |
rootDir | Root of your source files |
paths | Path aliases for cleaner imports |
skipLibCheck | Skips type-checking of declaration files in node_modules |
esModuleInterop | Makes CommonJS imports work with ES module syntax |
The extends key lets you inherit from a base config, which is how shared configs like @tsconfig/strictest or Next.js's built-in config work. You get a sensible baseline and only override what your project needs.
Try It Yourself
This exercise demonstrates how strict settings work together. The Config interface uses all the patterns — optional properties, explicit nullability, and typed function parameters — that strict mode enforces:
interface ServerConfig {
host: string;
port: number;
ssl: boolean;
maxConnections: number | null; // explicitly nullable — null means unlimited
tags?: string[]; // optional — may be absent entirely
}
function describeServer(config: ServerConfig): string {
const connections = config.maxConnections === null
? "unlimited"
: `max ${config.maxConnections}`;
const tags = config.tags?.join(", ") ?? "none";
return [
`Host: ${config.host}:${config.port}`,
`SSL: ${config.ssl ? "enabled" : "disabled"}`,
`Connections: ${connections}`,
`Tags: ${tags}`,
].join("\n");
}
const dev: ServerConfig = {
host: "localhost",
port: 4000,
ssl: false,
maxConnections: 10,
tags: ["dev", "local"],
};
const prod: ServerConfig = {
host: "api.myapp.com",
port: 443,
ssl: true,
maxConnections: null, // unlimited connections in prod
};
console.log("--- Dev ---");
console.log(describeServer(dev));
console.log("\n--- Prod ---");
console.log(describeServer(prod));
// Try adding a new required field to ServerConfig and watch
// TypeScript immediately flag both objects above
Arrange the code
Reassemble a program that layers config the way a project does: start from defaults, produce a merged config that overrides the port, read retries with a ?? fallback (the strictNullChecks-friendly default), build a summary string, and log it. The lines are shuffled. Each const consumes the binding above it, so only one order runs top-to-bottom and logs port 8080, retries 3.
const defaults = { port: 3000, retries: 3 };const merged = { ...defaults, port: 8080 };const retries = merged.retries ?? 0;console.log(summary);const summary = `port ${merged.port}, retries ${retries}`;
Build a Config Layer
The build below turns that merge into a reusable config layer — and it demonstrates something runtime-observable that this lesson is about: the difference strictNullChecks pushes you toward between "missing" and "falsy" (?? versus ||), which is a real difference in the JavaScript the compiler emits. This is a build task: a small program that reports its own pass/fail. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
Build
Finish the build. Three functions are stubbed, and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// A config layer: defaults, overridden by a (partial) user config. Under strict
// null checking the compiler forces you to treat "missing" and "falsy" as DIFFERENT
// things — which is a real RUNTIME difference between ?? and ||. Do NOT change these.
interface ServerConfig {
host: string;
port: number;
retries: number;
verbose: boolean;
}
const DEFAULTS: ServerConfig = {
host: "localhost",
port: 3000,
retries: 3,
verbose: false,
};
// A partial override — only some keys are present; a missing key is undefined.
type ConfigOverride = Partial<ServerConfig>;
// TODO 1: resolve ONE numeric setting. Return the override if it was PROVIDED (even
// if it is 0), otherwise the fallback. Use ?? so that a provided 0 is kept and only
// a MISSING (undefined) value falls back — this is the strictNullChecks distinction.
// resolveNumber(0, 3) -> 0 resolveNumber(undefined, 3) -> 3
function resolveNumber(override: number | undefined, fallback: number): number {
// your code here
return fallback; // replace this
}
// TODO 2: merge an override onto DEFAULTS, returning a full ServerConfig. Every key
// present in the override wins; missing keys keep their default. Spread the override
// LAST so its keys override.
// mergeConfig({ port: 8080 }).port -> 8080 ; .host -> "localhost"
function mergeConfig(override: ConfigOverride): ServerConfig {
// your code here
return DEFAULTS; // replace this
}
// TODO 3: format a config as "host:port (retries=N, verbose=on/off)".
// describe(DEFAULTS) -> "localhost:3000 (retries=3, verbose=off)"
function describe(config: ServerConfig): string {
// your code here
return ""; // replace this
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(resolveNumber(0, 3), 0, "TODO 1: a provided 0 must be kept, not replaced by the fallback (?? not ||)");
assert.strictEqual(resolveNumber(undefined, 3), 3, "TODO 1: a missing value should fall back to the default");
const merged = mergeConfig({ port: 8080, retries: 0 });
assert.strictEqual(merged.port, 8080, "TODO 2: a provided key should override the default");
assert.strictEqual(merged.retries, 0, "TODO 2: a provided 0 should override, not be treated as missing");
assert.strictEqual(merged.host, "localhost", "TODO 2: a missing key should keep its default");
assert.strictEqual(merged.verbose, false, "TODO 2: a missing key should keep its default");
assert.strictEqual(describe(DEFAULTS), "localhost:3000 (retries=3, verbose=off)", "TODO 3: describe should format the config");
console.log("All checks passed.");
console.log("Resolved 0:", resolveNumber(0, 3));
console.log("Merged:", describe(mergeConfig({ port: 8080, retries: 0 })));
console.log("Defaults:", describe(DEFAULTS));Expected output: All checks passed.
Resolved 0: 0
Merged: localhost:8080 (retries=0, verbose=off)
Defaults: localhost:3000 (retries=3, verbose=off)
Once it passes, try two variations and predict each before running:
- Use
||instead of??. InresolveNumber, changeoverride ?? fallbacktooverride || fallback. Predict which check fails first before running.||falls back on ANY falsy value, and0is falsy — soresolveNumber(0, 3)returns3instead of0, and TODO 1's first check fires withAssertionError: TODO 1: a provided 0 must be kept, not replaced by the fallback (?? not ||)and3 !== 0. An instructive assert failure showing the exact runtime gap between??(falls back only on null/undefined) and||(falls back on any falsy) — the distinctionstrictNullChecksis designed to make you notice. - Override the verbose flag. After the checks pass, add
console.log("Verbose merge:", describe(mergeConfig({ verbose: true, host: "0.0.0.0" })));below the logs. Predict the new line before running. The merge takesverboseandhostfrom the override and everything else from the defaults, so you getVerbose merge: 0.0.0.0:3000 (retries=3, verbose=on). This changes the echoed output, not any check, and shows the same base-then-override layering across a different set of keys.
Key Takeaways
tsconfig.jsoncontrols both how TypeScript checks your code and what JavaScript it emits — these are two separate concerns"strict": trueis a bundle of checks; the most important arestrictNullChecksandnoImplicitAny- With
strictNullChecks,nullandundefinedmust be declared explicitly and handled with narrowing or the??operator noImplicitAnyprevents silent type loss on unannotated parameters — use generics when you need flexibilitytargetcontrols output syntax;libcontrols which built-in types are available — they are independent- Use
extendsto inherit a shared base config and only override what your project needs skipLibCheck: trueis common in real projects to avoid type errors in third-partynode_modules
Pro Tip: Start new projects with
"strict": truefrom day one. Enabling it on an existing codebase later is painful — you will surface dozens of latent bugs at once. If you inherit a codebase without strict mode, enable the flags one at a time: start withnoImplicitAny, thenstrictNullChecks, and fix the errors incrementally before moving to the next flag.
Next Steps
You've configured how the compiler checks your code — but strict mode only guards data whose types you already know. Data crossing your program's boundary (an API response, a form, a config file) arrives untyped at run time. Next, you'll learn Zod: schemas that validate untrusted input at run time and infer the TypeScript types for you, closing the gap between compile-time safety and runtime reality.
Ready to continue? Head to Zod and Validation!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.