Starting with TypeScript: What the Compiler Checks, tsconfig Options That Matter, and Your First Errors

Key takeaways

TypeScript checks your code before it runs and then erases every type, so nothing is checked at runtime. This guide covers a local install, the handful of tsconfig options a beginner actually needs, running .ts files directly with Node or tsx versus compiling, and what the first compiler errors you will hit (TS2322, TS2345, TS7006, TS2307, TS18047/18048) really mean.

TypeScript is JavaScript with a type checker attached. You write annotations like name: string, the compiler checks that every use of name is consistent, and then it deletes the annotations and hands you ordinary JavaScript. That last step is the single most important thing to understand as a beginner, because it explains both what TypeScript is good at and where it will quietly let you down.

This first article in the series covers setup and the mental model: what the compiler checks, how to install it, which tsconfig.json options actually matter on day one, how to run .ts files, and how to read the first errors you will meet. Types themselves (unions, interfaces, generics) come in the following parts.

All commands and error messages below were reproduced with TypeScript 7.0.2 and Node.js 24.13.1. Error codes are stable across versions; the default tsconfig.json generated by tsc --init has changed between major versions, so yours may look slightly different.

What TypeScript actually checks (and what it does not)

Here is the classic JavaScript surprise:

function add(a, b) {
  return a + b;
}
add(10, 20);     // 30
add("10", 20);   // "1020" - no error, just a wrong answer

With types, the second call is rejected before the program ever runs:

function add(a: number, b: number): number {
  return a + b;
}
add(10, 20);
add("10", 20);
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

That is the value proposition: a whole class of mistakes (wrong argument types, typos in property names, forgetting that something may be null) is found in the editor instead of in production. The same type information powers autocomplete and makes renames safe, because the compiler knows every place a function or property is used.

What TypeScript does not do is check anything at runtime. To prove it, here is a file run directly with Node, which strips types without checking them:

// run.ts
function add(a: number, b: number): number { return a + b; }
console.log(add(2, 3));
console.log(add("2" as any, 3));
$ node run.ts
5
23

The annotation a: number did not stop a string from arriving. The emitted code is just function add(a, b) { return a + b; }. Inside your own code base this rarely matters, because the compiler has already rejected the bad call sites. It matters a lot at the boundaries: JSON.parse, fetch(...).then(r => r.json()), process.env, query strings, files, message queues. Those values come from outside the type checker, and whatever type you write on them is a claim, not a check.

interface User { id: number; name: string }

const res = await fetch("/api/users/1");
const user = (await res.json()) as User; // this is a promise you are making, not a check

If the server sends { "id": "1", "fullName": "Alice" }, TypeScript is perfectly happy and user.name.toUpperCase() crashes at runtime.

I learned this the usual way: I typed an API response with an interface, the code compiled cleanly, and everything worked until the backend changed a field from a number to a string ID. Nothing failed at build time because nothing could fail at build time; the error surfaced much later, far from the fetch call, as a comparison that was silently always false. Since then I treat every external value as unknown until it has passed a runtime check, either handwritten type guards (covered in the type narrowing article) or a schema library such as Zod that validates and produces the type in one step.

Installing TypeScript: local, npx, or global

TypeScript is an npm package. You need Node.js (the current LTS is fine) and a project folder:

mkdir ts-intro && cd ts-intro
npm init -y
npm install --save-dev typescript
npx tsc --version

There are three ways to get a tsc command, and they are not equivalent:

ApproachCommandWhen it fits
Local devDependencynpm i -D typescript, then npx tscEvery real project. The version is pinned in package.json and lockfile, so you, CI and teammates compile with the same compiler.
One-off npxnpx -p typescript tsc --init outside a projectQuick experiments where you do not want a package.json. Downloads a version on demand.
Globalnpm i -g typescriptConvenient for scratch files, but the global version drifts away from what projects use.

The reason to prefer the local install is that TypeScript releases regularly tighten checks and change defaults. With a global install you eventually get the confusing situation where tsc on your machine reports errors that CI does not (or the reverse), simply because they are different compilers. Editors such as VS Code can also be pointed at the workspace version (“TypeScript: Select TypeScript Version” then “Use Workspace Version”), which keeps the red squiggles consistent with your build.

tsconfig.json: the options that matter on day one

Create a config with:

npx tsc --init

The generated file is mostly comments. With TypeScript 7.0.2 the active part looks like this (trimmed):

{
  "compilerOptions": {
    // "rootDir": "./src",
    // "outDir": "./dist",
    "module": "nodenext",
    "target": "esnext",
    "types": [],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true
    // ...plus sourceMap, declaration, jsx and a few others
  }
}

Older tutorials show "module": "commonjs" and "target": "es2016"; that was the old --init output. You do not need to understand every option. These are the ones worth deciding deliberately.

strict

Keep "strict": true. It turns on a family of checks, the two most important being noImplicitAny (parameters without annotations are an error rather than silently any) and strictNullChecks (null and undefined are separate types you must handle). Turning strict off makes the first week easier and every later week worse, because most of the bugs TypeScript is good at catching are exactly the ones strict enables. When migrating an existing JavaScript project you might start with it off, but for new code there is no good reason to.

target and lib

target is the JavaScript version the compiler emits. If you run on a current Node version or ship through a bundler, a modern target (es2022 or esnext) is fine; older targets make the compiler down-level syntax such as optional chaining. lib controls which built-in APIs the type checker knows about. It follows target by default and includes DOM types, which is why document type-checks even in a Node project. For a pure Node project you can set "lib": ["esnext"] so that accidentally using window is an error.

module and moduleResolution

This pair causes more beginner confusion than everything else combined, because it has to match how your code is actually run:

  • Code run directly by Node (a CLI, a server): use "module": "nodenext". TypeScript then follows Node’s real rules, including the fact that ES modules need explicit file extensions in relative imports and that "type": "module" in package.json decides whether a .ts file is treated as ESM or CommonJS.
  • Code processed by a bundler (Vite, webpack, esbuild, Next.js): use "module": "esnext" (or "preserve") with "moduleResolution": "bundler". Bundlers accept extensionless imports and package exports conditions that Node alone would not.

With nodenext, an extensionless import is an error, and the message tells you exactly what Node expects:

import { two } from "./util";
// error TS2835: Relative import paths need explicit file extensions in ECMAScript imports
// when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?

Yes, you write ./util.js even though the file on disk is util.ts, because the import refers to the file that will exist after compilation. Switch the same project to "module": "esnext", "moduleResolution": "bundler" and the extensionless import passes, because bundlers resolve it.

The failure mode I have hit, and have seen many others hit, is a mismatch between the two: a project configured with moduleResolution: "bundler" that is actually executed by plain Node. tsc is happy with import { two } from "./util", the build succeeds, and then Node refuses to start with Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../util'. The type checker was answering the question “would a bundler find this?” when the real question was “will Node find this?”. If a TypeScript project compiles but fails to load at runtime, compare module/moduleResolution to whatever actually runs the output before touching anything else.

rootDir, outDir and noEmit

If tsc produces your JavaScript, uncomment "rootDir": "./src" and "outDir": "./dist" so compiled files do not land next to your sources, then run npx tsc and node dist/index.js.

If something else produces the JavaScript (a bundler like Vite, a framework, or Node’s own type stripping), set "noEmit": true. tsc then becomes purely a type checker: npx tsc --noEmit in CI or a pre-commit script, while the other tool handles the build. This split is the norm in front-end projects, because bundlers transpile TypeScript by removing types without checking them, much like Node does.

types

The new --init output sets "types": [], which means no global type packages are loaded automatically. In a Node project that produces this error the moment you touch process:

error TS2591: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

Do what it says: npm i -D @types/node and "types": ["node"].

Your first program: compile, or run directly

// src/index.ts
function greet(name: string): string {
  return `Hello, ${name}!`;
}
console.log(greet("Alice"));

Option 1: compile with tsc. With rootDir/outDir set:

npx tsc
node dist/index.js

One gotcha: once a tsconfig.json exists, npx tsc src/index.ts (naming a file) is refused in recent versions:

error TS5112: tsconfig.json is present but will not be loaded if files are specified on commandline.
Use '--ignoreConfig' to skip this error.

Run plain npx tsc (or npx tsc -p .) so the config is used.

Option 2: run the .ts file with Node. Node can strip type annotations itself; the feature is enabled by default from Node 23.6 and 22.18. On Node 24 no flag is needed:

node src/index.ts

Node only removes types. It performs no type checking, and it rejects TypeScript features that need generated code:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

The same restriction applies to namespace blocks containing code and to constructor parameter properties (constructor(private x: number)). If you plan to run files this way, set "erasableSyntaxOnly": true so tsc flags those constructs up front (error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.). Also note that Node requires real file extensions, so imports are written ./util.ts, and tsc then asks for "allowImportingTsExtensions": true (without it you get error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.).

Option 3: tsx. npx tsx src/index.ts uses esbuild under the hood, supports enums and the other non-erasable syntax, and has a watch mode (npx tsx watch src/index.ts) that replaces the older ts-node plus nodemon combination. Like Node, it does not type-check.

Whichever runner you use, the type check is a separate step. A reasonable package.json for a small Node project:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Reading the first errors you will hit

TypeScript error messages look intimidating, but almost all early ones are variations of a handful. Each code below is the exact output from the compiler.

TS2322: a value does not match a declared type.

let count: number = "5";
// error TS2322: Type 'string' is not assignable to type 'number'.

“Not assignable to” is TypeScript’s way of saying “does not fit”. Read the message as Type <what you have> is not assignable to type <what was expected>. Fix the value or, if the declaration is wrong, the declaration. Don’t silence it with as number, which asserts without converting (Number("5") converts).

TS2345: the same mismatch, for a function argument.

add(1, "2");
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

TS7006: a parameter has no type and strict mode will not guess.

function greet(name) { return "hi " + name; }
// error TS7006: Parameter 'name' implicitly has an 'any' type.

TypeScript infers the types of variables and return values well, but not of function parameters, because the function body cannot tell it what callers will pass. Annotate parameters; leave return types and local variables to inference unless you want the explicit contract.

TS2307: a module cannot be found.

import express from "express";
// error TS2307: Cannot find module 'express' or its corresponding type declarations.

Check three things in order: the package is actually installed; the package ships types or has a @types/... package (npm i -D @types/express); and your module/moduleResolution matches how the project runs (see above). Many packages now include their own declarations, so @types is only needed for those that do not.

TS18047 / TS18048: something might be null or undefined.

function first(el: HTMLElement | null) { return el.id; }
// error TS18047: 'el' is possibly 'null'.

function len(s?: string) { return s.length; }
// error TS18048: 's' is possibly 'undefined'.

This is strictNullChecks working. document.querySelector returns Element | null, optional parameters are T | undefined, Array.prototype.find may return undefined. Handle the missing case with an if check, optional chaining (el?.id) or a default (s ?? ""). The non-null assertion el!.id also compiles, but it is a promise that the value exists, and if you are wrong it becomes the same runtime TypeError TypeScript was trying to prevent.

any versus unknown

Both accept any value. The difference is what you are allowed to do with it afterwards.

let a: any = JSON.parse("1");
a.toFixed(2);        // compiles; would crash if a were not a number
a.foo.bar.baz();     // also compiles

let v: unknown = JSON.parse("1");
v.toFixed(2);
// error TS18046: 'v' is of type 'unknown'.

if (typeof v === "number") {
  v.toFixed(2);      // OK: narrowed to number
}

any switches the type checker off for that value, and it spreads: anything you read from an any is also any. unknown means “I have not checked this yet” and forces you to narrow it before use. That makes unknown the right type for anything crossing a boundary (parsed JSON, catch (err) values, untyped library results), and it is exactly the discipline the API-response problem above calls for. Reserve any for short-term migration of JavaScript code, and consider enabling a lint rule that flags it.

Editor setup

VS Code ships TypeScript support built in; the language service that draws red squiggles is the same checker tsc uses. You do not need an extension to get errors, autocomplete, go-to-definition or rename. A few settings are worth knowing:

  • Use the workspace TypeScript version (command palette: “TypeScript: Select TypeScript Version”), so the editor and npx tsc agree.
  • Hover to read inferred types. Hovering a variable shows what TypeScript thinks it is. When an error message is confusing, hovering the pieces involved is usually faster than reading the message twice.
  • Restart the TS server (“TypeScript: Restart TS Server”) after changing tsconfig.json or installing @types packages if the editor keeps showing stale errors.
  • ESLint with typescript-eslint adds rules the compiler does not enforce, such as flagging any or floating promises. Prettier handles formatting. Neither replaces tsc --noEmit in CI.

A small end-to-end example

Putting the pieces together: a tiny script that reads a number from the command line, validates it at the boundary, and uses a union type so the compiler checks every operation is handled.

// src/calc.ts
type Operation = "add" | "subtract" | "multiply" | "divide";

function calculate(a: number, b: number, op: Operation): number {
  switch (op) {
    case "add":      return a + b;
    case "subtract": return a - b;
    case "multiply": return a * b;
    case "divide":
      if (b === 0) throw new Error("Cannot divide by zero");
      return a / b;
  }
}

function parseNumber(raw: string | undefined): number {
  const n = Number(raw);
  if (raw === undefined || Number.isNaN(n)) {
    throw new Error(`Expected a number, got ${JSON.stringify(raw)}`);
  }
  return n;
}

const [, , rawA, rawB] = process.argv;   // string | undefined, not number
console.log(calculate(parseNumber(rawA), parseNumber(rawB), "divide"));
npm i -D @types/node      # for process
node src/calc.ts 10 4     # 2.5
npx tsc --noEmit          # the actual type check

Two details are worth noticing. process.argv values are strings from outside the program, so they go through parseNumber rather than being declared as numbers. And because op is a union and every member is handled, TypeScript knows the switch always returns; add a fifth operation to the union without a matching case and the function gets flagged (error TS2366: Function lacks ending return statement and return type does not include 'undefined'.), which is the kind of refactoring safety that makes the setup worthwhile.

Where to go next

The rest of the series builds on this setup: union and intersection types, interfaces, then generics and utility types. If you are coming from an existing TypeScript 4 code base, What TypeScript 5 Changed covers satisfies, standard decorators and the migration differences.