TypeScript Setup for JavaScript Developers

Professional TypeScript starts with a deterministic setup. If compiler settings vary per developer, the checker disagrees with itself across machines and every "works on my machine" argument becomes unfalsifiable. This lesson installs TypeScript the way production teams do: local to the project, strict from the first commit, and with a verified feedback loop.

Why installation is a design decision

Two decisions dominate everything else in this lesson: where the compiler lives and where the configuration lives. Get both right and the rest of the track runs identically on every machine that clones the repo.

The compiler is an npm package, not a global tool

Installing typescript globally (npm install -g typescript) is the classic beginner mistake. A global compiler means every project on your machine uses whichever version you last installed — but different projects need different compiler versions, because the checker's behavior changes between releases. Version skew between teammates is then invisible and unreproducible.

The fix: TypeScript is a dev dependency, pinned per project like any other tool. npx tsc then resolves the project-local binary, and CI uses exactly the same one.

One tsconfig per repository, not per developer

The tsconfig.json file is the project's contract with the checker: which files compile, how strictly, and what JavaScript comes out. It is committed to version control. Personal settings belong in your editor, never in the config that CI reads. A teammate's IDE warnings are irrelevant; tsc --noEmit in the pipeline is the single source of truth.

Your first strict project

Time to build one. The example below walks through the exact commands and configuration used by every later lesson in this track.

Install and initialize

Purpose of the example: create a minimal project with a pinned compiler and a generated starter config, so the checker runs from the very first file.

# Create the project and enter it
mkdir ts-lab && cd ts-lab && npm init -y

# TypeScript is a dev dependency: pinned in package.json, never global
npm install --save-dev typescript

# Generate a starter tsconfig.json to edit
npx tsc --init

# Verify the project-local compiler answers
npx tsc --version

What you should see: a version number like Version 5.9.2 — the exact version in your devDependencies, not whatever is installed globally. If npx tsc prints a different version than your package.json pins, something upstream is shadowing it.

A tsconfig you can grow with

tsc --init generates hundreds of commented options — useful as a reference, overwhelming as a starting point. Replace it with this deliberately small configuration, which is the baseline for this entire track:

Purpose of the example: a strict-but-pragmatic config: the full strict family, plus three extra flags that close the most common remaining loopholes.

{
  "compilerOptions": {
    "target": "ES2022",            // emit modern JS; no point transpiling to 2016
    "module": "ESNext",            // emit import/export; bundlers and Node handle it
    "moduleResolution": "Bundler", // resolution rules matching Vite/esbuild
    "strict": true,                // the whole strict family, non-negotiable
    "noUncheckedIndexedAccess": true,  // arr[i] can be undefined — index with care
    "exactOptionalPropertyTypes": true, // {a?: string} ≠ {a: string | undefined}
    "noImplicitOverride": true,    // overrides must say `override`
    "skipLibCheck": true           // don't type-check node_modules' .d.ts files
  },
  "include": ["src"]
}

What you should see: tsc --noEmit on an empty src/ exits silently with code 0. Silence is success — the compiler reports only problems.

The flags that matter, explained

Each flag exists because a specific category of JavaScript bug escaped every previous check:

  • strict enables the family at once: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables and alwaysStrict. The two you will feel daily are noImplicitAny (no value silently becomes "anything goes") and strictNullChecks (null/undefined are no longer assignable to every type — the single biggest bug class it eliminates).
  • noUncheckedIndexedAccess makes array indexing honest: items[i] has type T | undefined, because at runtime it genuinely might be.
  • exactOptionalPropertyTypes distinguishes "property absent" from "property present with value undefined" — a distinction JavaScript conflates and APIs care about.
  • noImplicitOverride requires override on subclass methods that replace a base method, catching renamed-base-method typos.
  • skipLibCheck skips re-checking dependency declaration files — a build-time decision, not a safety compromise in your code.

Running TypeScript three ways

Checking and running are separate concerns, and you have three practical options. Choosing correctly saves you from both slow loops and missing checks.

tsc: check and emit

Purpose of the example: the canonical loop — check everything, emit JavaScript only when the check passes.

npx tsc              # read tsconfig.json, check src/, emit JS
npx tsc --noEmit     # check only — the CI command
npx tsc --watch      # re-check on every save: your inner loop

What you should see: with an error present, a diagnostic like src/index.ts:3:7 - error TS2322: Type 'string' is not assignable to type 'number'. Note the exit code is non-zero — that is what makes the CI gate work.

tsx: run directly, with full support

tsx executes TypeScript directly, including features that plain type stripping cannot handle (enums, namespaces, parameter properties, decorators) and tsconfig paths:

npm install --save-dev tsx
npx tsx src/index.ts      # run it now — no emit step

What you should see: your program's output immediately. Note what you do not see: type errors. tsx strips types without checking — running does not replace checking, which is exactly why tsc --noEmit exists.

Node's native type stripping

Recent Node.js versions run type-stripped TypeScript natively: type stripping arrived in v22.6.0, is enabled by default since v22.18.0/v23.6.0, and is stable since v24.12.0/v25.2.0. No tsx, no emit step — Node itself removes the annotations before execution.

Purpose of the example: know when Node's built-in support is enough and when it is not.

// strip-demo.ts — runs on Node ≥ 22.18 with zero tooling:
//   node strip-demo.ts
interface Greeting {            // erased — interfaces are pure type syntax
  name: string;
}

const greet = (g: Greeting): string => `Hello, ${g.name}`;

console.log(greet({ name: "Ada" }));

What you should see: Hello, Ada. Now swap the interface for an enum or a namespace containing runtime code — Node fails with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, because stripping only removes types; features that need transformation are out of scope. Use tsx for those. Two more Node rules to remember: type-only imports must use the type keyword (import type { Greeting } from …), and Node does not read tsconfig.json — it never checks, and paths aliases do not work.

Watch mode and the editor feedback loop

Your real feedback loop is the editor: VS Code's TypeScript language server runs the same checker continuously, so errors appear as you type. The discipline to build: treat editor squiggles and tsc --noEmit as one system — the editor for instant feedback, the CLI for the authoritative pass. Keep tsc --watch in a second terminal when working across files, because the editor only re-checks what you have open.

The CI gate

Setup is only professional once automation enforces it. The pipeline needs exactly one new script:

One script is the whole gate

Purpose of the example: wire the checker into package.json so CI and humans run the identical command.

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc",
    "test": "node --test"
  }
}

Why CI, and not just the IDE

What you should see: npm run typecheck passing in CI on every pull request. If the type check is not in CI, the config is a suggestion, not a contract — contributors with differently configured editors will silently erode it.

Practice: make the setup prove itself

Break it on purpose

Add strict: false (or delete it), then reintroduce the runtime bug from the why lesson: read a property that does not exist on a plain object. What you should see: with strict mode off, the checker is silent; restore strict: true and the same line errors immediately. You have now personally verified that the flag list above is not ceremony.

Setup checklist

Before the next lesson, confirm every box: the compiler is in devDependencies; tsconfig.json is committed with strict plus the four extra flags; npm run typecheck exits 0; a deliberately broken line produces a non-zero exit; and the CI pipeline runs the same script. With the loop verified, the compiler lesson explains what tsc actually did during those silent passes.