Skip to main content

You're staring at your screen. The terminal is full of red. Twenty-three errors. You only changed one line. You have no idea what any of these messages mean. Your first instinct is to close the terminal and pretend you never installed TypeScript.

We've all been there. The TypeScript compiler can feel like that one code reviewer who rejects your pull request because you used single quotes instead of double quotes. Pedantic. Nitpicky. In your way.

But here's the thing: that code reviewer is trying to save you. And once you understand how they think, you'll want them on every pull request.

This chapter is about transforming your relationship with the TypeScript compiler. By the end, you'll see it not as an obstacle but as the most thorough, tireless, and helpful code reviewer you've ever had.

The Compiler's Job (It's Not What You Think)​

Most people think the TypeScript compiler has one job: turn .ts files into .js files. That's wrong. Or at least, it's only 10% of the job.

The TypeScript compiler's real job is to verify that your code is internally consistent. It reads every line you write. It builds a map of every type, every variable, every function signature. And then it checks: does the way you USE things match the way you DEFINED them?

If getUser() says it returns { name: string; age: number }, but you're accessing user.email — the compiler flags it. Not because it's mean. Because you made a promise (getUser returns this shape) and then broke it (you're accessing something that doesn't exist in that shape).

The compiler is a promise-enforcement machine. Every type annotation is a promise. Every function call is a claim that you're keeping your promise. The compiler just verifies the claims.

tsconfig.json: The Control Panel​

The tsconfig.json file is how you tell the compiler what kind of promises you want to enforce. It's the difference between "please gently suggest that this might be a problem" and "this code must be provably correct or it doesn't compile."

Let's build a tsconfig.json from scratch and understand every important option:

{
"compilerOptions": {
// TYPE CHECKING (the important stuff)
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,

// MODULES (how files connect)
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,

// OUTPUT (what the compiler produces)
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,

// DEVELOPER EXPERIENCE
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Let's go through the options that actually matter.

strict: true​

This is the master switch. It enables seven separate strictness checks at once:

FlagWhat It DoesThe Bug It Prevents
strictNullChecksnull and undefined are distinct types"Cannot read properties of null"
strictFunctionTypesFunction parameter types are checked contravariantlyPassing the wrong callback shape
strictBindCallApplybind, call, apply are type-checkedWrong arguments to bound functions
strictPropertyInitializationClass properties must be initializedUsing undefined class fields
noImplicitAnyVariables must have inferrable typesAccidentally untyped code
noImplicitThisthis must have a known typeWrong this context
alwaysStrictEmits "use strict" in outputSloppy mode behavior

If you take one thing from this chapter, take this: always use "strict": true. It's the difference between TypeScript as a linter and TypeScript as a type system. Without it, you're getting maybe 40% of TypeScript's value.

noUncheckedIndexedAccess​

This is not part of strict, but it should be. Consider:

const users: User[] = [];
const firstUser = users[0]; // TypeScript says this is User
console.log(firstUser.name); // Runtime error! firstUser is undefined

Without noUncheckedIndexedAccess, TypeScript assumes every array access succeeds. With it:

const firstUser = users[0]; // Type: User | undefined
console.log(firstUser.name);
// ~~~~~~~~~~
// Error: 'firstUser' is possibly 'undefined'.

Now you're forced to handle the empty-array case. This flag has saved me more times than I can count.

exactOptionalPropertyTypes​

Another flag that should be part of strict:

interface Config {
timeout?: number;
}

const config: Config = { timeout: undefined };
// Without exactOptionalPropertyTypes: OK
// With exactOptionalPropertyTypes: Error!
// 'timeout' is optional, so it should be OMITTED, not set to undefined.

This prevents the subtle bug where undefined and "not present" mean different things but TypeScript treats them the same.

Module Options​

The module options control how TypeScript resolves imports. The modern recommendation:

{
"module": "ESNext",
"moduleResolution": "bundler"
}

"module": "ESNext" tells TypeScript you're using ES modules (import/export). "moduleResolution": "bundler" tells it you're using a bundler (Vite, Webpack, etc.) that understands how to resolve imports the way Node.js and browsers do.

If you're writing for Node.js directly (no bundler), use:

{
"module": "NodeNext",
"moduleResolution": "NodeNext"
}

Output Options​

{
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
}
  • target: What JavaScript version to output. ES2022 is widely supported and gives you top-level await, class fields, and other modern features without transpilation.
  • outDir: Where compiled .js files go. Keep them separate from your .ts source.
  • rootDir: Where your .ts source lives. This mirrors your source structure in the output.
  • declaration: Generates .d.ts files — type definitions for consumers of your code. Essential for libraries.
  • sourceMap: Generates source maps so debuggers show your .ts code, not the compiled .js.

How the Compiler Thinks: A Mental Model​

Understanding the compiler's internal process makes error messages make sense. Here's the simplified model:

Phase 1: Parsing​

The compiler reads every .ts file and builds an Abstract Syntax Tree (AST). This is the same thing JavaScript engines do. At this stage, it's just understanding the structure of your code — what's a function, what's a variable, what's an import.

Phase 2: Type Checking (The Important One)​

This is where TypeScript does what JavaScript engines don't. The compiler:

  1. Collects all type declarations. Every interface, type, class, and annotation gets registered in a global type table.

  2. Infers types where you didn't annotate. If you write let x = 5, TypeScript infers x: number. If you write const arr = [1, 2, 3], it infers arr: number[]. This is called type inference, and it's the reason you don't need to annotate everything.

  3. Checks every operation against the type table. When you write user.name, TypeScript looks up the type of user, checks if it has a name property, and verifies the types match. When you call a function, it checks each argument against the parameter types.

  4. Reports mismatches. If any check fails, it emits an error with the file, line, column, and a description of what went wrong.

Phase 3: Emit​

After type checking passes, the compiler strips all type annotations and emits clean JavaScript. This is why TypeScript has zero runtime cost — the types literally don't exist in the output.

The Key Insight​

Type checking happens BEFORE emit. If there are type errors, the compiler still emits JavaScript by default (you can change this with noEmitOnError: true). This is intentional: it lets you migrate a JavaScript project incrementally. You can have type errors in one file while another file compiles fine.

But here's the mental model shift: treat type errors as real errors. Don't ignore them. Don't suppress them with as any. Every type error is the compiler saying, "I can't prove this is correct." Sometimes the compiler is wrong (it's conservative by nature). But most of the time, it's right, and you just haven't understood the problem yet.

Reading Error Messages Like a Pro​

TypeScript error messages follow a pattern. Once you learn to read them, they go from cryptic to helpful.

The Anatomy of a TypeScript Error​

src/users.ts:15:8 - error TS2339: Property 'emial' does not exist
on type 'User'. Did you mean 'email'?

15 console.log(user.emial);
~~~~~~~~~

src/users.ts:3:3
3 email: string;
~~~~~~~~~~~~~
'email' is declared here.

Let's break this down:

  1. Location: src/users.ts:15:8 — file, line, column. Click it in your terminal (most terminals support clickable file paths).
  2. Error code: TS2339 — every error has a unique code. Google "TS2339" for detailed docs.
  3. Message: "Property 'emial' does not exist on type 'User'." — what's wrong.
  4. Suggestion: "Did you mean 'email'?" — TypeScript often suggests fixes.
  5. Pointer: ~~~~~~~~~ — exactly which part of the line is the problem.
  6. Context: The relevant type declaration, so you can see what IS available.

Common Error Patterns​

TS2345: Argument of type 'X' is not assignable to parameter of type 'Y'.

This is the most common error. It means you're passing the wrong type to a function. The fix is usually: check the function signature, or check what you're passing.

TS2339: Property 'X' does not exist on type 'Y'.

You're accessing a property that doesn't exist. Either you have a typo, or the type is wider than you think (it might be a union, and only some members have that property).

TS2532: Object is possibly 'undefined'.

You're accessing something that might be undefined. Add a guard: if (value !== undefined) or use optional chaining: value?.property.

TS18046: 'X' is of type 'unknown'.

You're trying to use an unknown value without narrowing it first. unknown is the safe version of any — you can't do anything with it until you prove what it is.

The Compiler Is Conservative (And That's Good)​

Here's a scenario that frustrates beginners:

function getFirstElement(arr: number[]): number {
return arr[0];
// ~~~~~
// Error: Type 'number | undefined' is not assignable
// to type 'number'. (with noUncheckedIndexedAccess)
}

"But I KNOW the array has elements!" you think. "I just pushed to it three lines ago!"

The compiler doesn't know that. It can't know that. It only knows that arr[0] returns number | undefined because arrays can be empty. It's being conservative — it's assuming the worst case because the worst case is what causes production bugs.

The fix isn't to suppress the error. It's to handle the case:

function getFirstElement(arr: number[]): number {
const first = arr[0];
if (first === undefined) {
throw new Error("Array is empty");
}
return first; // TypeScript now knows first is number
}

Or, if you're absolutely certain:

function getFirstElement(arr: [number, ...number[]]): number {
return arr[0]; // OK — tuple guarantees at least one element
}

The compiler isn't fighting you. It's asking you to be explicit about your assumptions. And explicit assumptions are the difference between "it works on my machine" and "it works."

Configuring for Real Projects​

Here are my recommended tsconfig.json settings for different project types:

Frontend App (React, Vue, Svelte with Vite)​

{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}

Node.js Backend​

{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

Library / npm Package​

{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

The Compiler Is Your First User​

Here's a reframe that changed how I think about TypeScript: the compiler is your first user. Before any human runs your code, the compiler "runs" it — not executing it, but verifying it. It's the first consumer of your API. It's the first caller of your functions. It's the first reader of your types.

And like any user, it will tell you when your API is confusing. When your types are inconsistent. When your function signatures don't make sense. The difference is that the compiler tells you immediately, in your editor, while you can still fix things — not in a bug report three weeks later.

Treat the compiler's feedback as a gift. Every error is a bug that didn't make it to production. Every warning is a design issue you can fix before it becomes technical debt. Every "Did you mean...?" suggestion is a typo that didn't confuse your users.

Try This: Compiler Configuration​

  1. Create a new TypeScript project with tsc --init.
  2. Enable strict: true, noUncheckedIndexedAccess: true, and exactOptionalPropertyTypes: true.
  3. Write a function that accesses an array index without checking for undefined. See the error.
  4. Fix it with a type guard.
  5. Write a function that takes an options parameter with an optional property. Try passing undefined explicitly. See the error with exactOptionalPropertyTypes.
  6. Change strict to false and see how many errors disappear. Then change it back. Feel the difference.

Time needed: 15 minutes.

What to notice: How strict: true transforms TypeScript from a gentle suggester into a rigorous verifier. The errors aren't annoyances — they're the compiler doing its job.

The Revelation​

Here's what you now know that most TypeScript developers don't fully internalize until years in: the compiler is not testing your code. It's testing your DESIGN.

When the compiler flags a type error, it's not saying "you made a mistake." It's saying "your design has a hole." The function says it returns a User, but it might return null. The interface says email is required, but the API sometimes omits it. The array says it contains number, but somewhere you're pushing a string.

These are design problems. And the compiler finds them at design time — before you've written tests, before you've deployed, before anyone depends on your API. That's the revelation: TypeScript doesn't just catch bugs. It makes you a better designer.

In the next chapter, we leave the compiler behind and dive into the type system itself. You'll learn the fundamental types — the building blocks of every TypeScript program — and you'll discover why any is the most dangerous word in the language.


In the next chapter: primitives, arrays, tuples, and the any type — why it exists, why it's tempting, and why you should treat it like a fire extinguisher (break glass only in emergencies).