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.
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.
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": "ES2017" means async/await compiles down to promise chains. Setting "target": "ES2022" means the output keeps modern syntax intact.
// 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
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 the compiler — now for the final skill: what to do when something goes wrong. In the last lesson, you'll learn how to read TypeScript error messages, use the type system as a debugging tool, write exhaustiveness checks, and apply runtime debugging techniques.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.