Skip to lesson

learningtypescript.org / basics / 24-config-and-tsconfig · lesson 24 of 25

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:

OptionPurpose
strictEnables all strictness checks at once
targetSets the output JavaScript version
moduleControls how imports/exports compile (CommonJS, ESNext)
libBuilt-in type definitions to include (DOM, ES2022)
outDirWhere compiled .js files are written
rootDirRoot of your source files
pathsPath aliases for cleaner imports
skipLibCheckSkips type-checking of declaration files in node_modules
esModuleInteropMakes 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.json controls both how TypeScript checks your code and what JavaScript it emits — these are two separate concerns
  • "strict": true is a bundle of checks; the most important are strictNullChecks and noImplicitAny
  • With strictNullChecks, null and undefined must be declared explicitly and handled with narrowing or the ?? operator
  • noImplicitAny prevents silent type loss on unannotated parameters — use generics when you need flexibility
  • target controls output syntax; lib controls which built-in types are available — they are independent
  • Use extends to inherit a shared base config and only override what your project needs
  • skipLibCheck: true is common in real projects to avoid type errors in third-party node_modules

Pro Tip: Start new projects with "strict": true from 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 with noImplicitAny, then strictNullChecks, 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.