TypeScript – Setting Up a Project

July 10, 20268 min readUpdated 9/5/2026

There is one compiler, tsc, and it does two separable jobs: it checks your code and it emits JavaScript. Almost everything confusing about modern TypeScript setups comes from the fact that most projects now let it do only the first.

The smallest possible setup

TypeScript is an ordinary dev dependency. There is nothing to install globally.

npm init -y
npm install --save-dev typescript
npx tsc --init

tsc --init writes a tsconfig.json. Its presence is what marks a directory as a TypeScript project — tsc with no arguments looks for it, reads the options and the file list from it, and ignores whatever is on the command line.

Write a file:

// src/greet.ts
export function greet(name: string): string {
  return `Hello, ${name}`;
}

console.log(greet('world'));

and compile it:

npx tsc              # writes src/greet.js
node src/greet.js    # Hello, world

That is the whole loop in its original form: tsc reads .ts, writes .js, Node runs the .js. Set outDir and the output lands in dist/ instead of beside the source, which is what you want for anything real.

Two flags are worth knowing on day one. --watch recompiles on save. --noEmit checks without writing anything — and that second one is where modern projects live.

Why most projects stop it emitting

If you are building for a browser, something already transforms your code: Vite, esbuild, SWC, webpack, the Angular CLI. That tool needs to bundle, split, minify, handle CSS and hash filenames. Removing type annotations is a trivial extra for it.

And it is much faster at that than tsc, for a reason worth understanding: those tools strip types without checking them. Erasing : string is a syntactic operation on one file. Verifying that : string is true requires building a model of the entire program.

Which leads to the trap. A bundler will happily build code that does not typecheck. It never looked.

So the two jobs get split: the bundler emits, and tsc checks and emits nothing. That is exactly what the React half of the pizza app does:

"scripts": {
  "dev": "vite",
  "build": "tsc -b && vite build",
  "lint": "oxlint",
  "preview": "vite preview",
  "typecheck": "tsc -b --noEmit",

tsc -b && vite build is the important line. The && is load-bearing: type errors fail the build before Vite runs at all. Drop it — run only vite build, as plenty of projects do by accident — and you have a deployment pipeline that ships type errors, silently, forever.

Note also what npm run dev does not do. Vite's dev server strips types and serves; it does not typecheck. Your editor does that as you work, which is why the gap goes unnoticed until CI or a teammate without the same editor setup.

noEmit is not a downgrade

The React app's config turns emitting off outright:

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

Read that as a statement of responsibility rather than a limitation. tsc is being used as a type checker — a linter of a very thorough kind — and Vite is the compiler. moduleResolution: "bundler" says the same thing from the other direction: resolve imports the way the bundler will, because the bundler is the one that has to find them.

allowImportingTsExtensions is only legal because of noEmit — writing ./money.ts in an import would produce broken JavaScript if TypeScript were emitting it, so the compiler refuses that combination.

Why there are three tsconfig files

Open tsconfig.json in that project and there is nothing in it:

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

This is project references, and it is what the -b ("build mode") flag in those scripts is for. The root config compiles nothing itself; it points at two real configs and tsc -b builds both.

The split exists because the two halves run in different places. Application code runs in a browser and needs DOM types. vite.config.ts runs in Node and needs Node's. Give both to both and you get a project where document autocompletes inside a build script and process.env autocompletes inside a component — each of which is a crash waiting to happen.

So the app config takes the DOM:

    "target": "es2023",
    "lib": ["ES2023", "DOM"],
    "module": "esnext",
    "types": ["vite/client"],

and the Node one takes Node:

    "target": "es2023",
    "lib": ["ES2023"],
    "types": ["node"],
    "skipLibCheck": true,

Same language, two environments, two configs. One include covers src, the other covers exactly one file — vite.config.ts.

Which files are in the project

A tsconfig decides its own file list, and getting this wrong produces the two most confusing beginner symptoms: errors from files you thought you had excluded, and no errors at all from files you thought you were checking.

Three settings control it. include takes glob patterns and is what you almost always want. exclude subtracts from include — it does not exclude a file that something else imports, which surprises people. files takes an explicit list and is for the rare case where you mean exactly these.

Both halves of the pizza app use the simple form. The React app's is one line:

  "include": ["src"]

The Angular app has to be more careful, because tests live beside the code they test:

  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "src/**/*.spec.ts"
  ]

One thing to know: exclude defaults to node_modules, bower_components, jspm_packages and your outDir. Write your own exclude and you replace that default, so remember to keep node_modules in it unless you enjoy typechecking your dependencies.

Turn strict on, on day one

The single most consequential line in a new tsconfig:

    "strict": true

It is not one check. It switches on a family of them — strictNullChecks, noImplicitAny, strictFunctionTypes and several more — and strictNullChecks alone accounts for most of the value people get out of TypeScript. Without it, every type silently includes null and undefined, and the runtime errors you adopted TypeScript to prevent are all still possible.

On a new project this costs nothing, because there is no existing code to fix. On an old one it is the last step rather than the first, as above. Both configurations in the pizza app have it on, and lesson 18 takes the family apart flag by flag.

Types for libraries you did not write

Your dependencies are shipped as JavaScript. TypeScript needs to know their shapes, and there are two ways it gets them.

The package ships its own. Modern libraries do — React Router, Redux Toolkit and the Stripe SDKs all include .d.ts files, and there is nothing to install.

Somebody else wrote them. For packages that do not, the community maintains declarations under the @types scope, which is why the dev dependencies list this:

    "@types/node": "^24.13.3",
    "@types/react": "^19.2.17",
    "@types/react-dom": "^19.2.3",

React itself is plain JavaScript; @types/react is the file that says what useState does. Install the wrong major version of it and you get errors that look like your code is wrong when the declarations are.

If a package has no types at all, importing it is an error under strict. The escape hatch is one line in a .d.ts file of your own:

declare module 'some-untyped-package';

Everything from it is then any — which is a decision, not a fix. More on that in Modules.

Running TypeScript without compiling it first

For a script or a one-off, the compile-then-run cycle is tedious. Two ways round it.

Node has understood TypeScript syntax natively since 22.6, and does it by stripping types — the same trick as a bundler, with the same caveat:

node src/greet.ts

No checking happens. Node deletes the annotations and runs what is left, so a file full of type errors runs perfectly. Useful, as long as you know that is what it is.

The other option is tsx, which does the same thing with better ergonomics and a watch mode:

npm install --save-dev tsx
npx tsx watch src/server.ts

Either way the rule from earlier holds: something still has to run tsc.

Adding TypeScript to a JavaScript project

You rarely start clean. The migration path is deliberately gradual, and it is worth knowing that you do not have to rename anything to begin.

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "strict": false,
    "noEmit": true
  },
  "include": ["src"]
}

allowJs lets .js files into the project. checkJs then typechecks them — inferring from JSDoc comments and from the code itself. That alone, on an untouched JavaScript codebase, will usually find real bugs before you have written a single annotation.

From there the order that works is: get it green with strict off, rename files to .ts a few at a time starting with the leaves, then turn on the strict flags one by one rather than all at once. tsconfig, the Flags That Matter goes through them individually, which is exactly how you want to enable them.

What does not work is renaming everything on a Friday and turning on strict. That produces four thousand errors, no way to tell the important ones from the noise, and a branch nobody merges.

The Angular setup, for contrast

The Angular half of the same application has none of this:

  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",

ng build typechecks, compiles templates, bundles and optimises in one step, because the Angular CLI owns the whole toolchain. There is no separate tsc line to forget.

The cost is that you get Angular's opinions with it, including its TypeScript version — the Angular app here is on 5.9.3 while the React one is on 6.0.3. That is not neglect; the compiler is part of the framework's supported set. It is the usual trade: less to assemble, less to choose.

The commands you will actually run

CommandWhat it does
npm run devdev server. Fast, and does not typecheck.
npm run typechecktsc -b --noEmit. The whole project, checked, no output.
npm run buildtypecheck, then bundle. Fails on the first type error.
npx tsc --noEmit --watcha terminal that goes red the moment you break something.

If you take one thing from this lesson: make sure something runs tsc in CI. The dev server does not, the bundler does not, and a project where only the editor checks types is a project whose type errors are one contributor away from being merged.

The failure is quiet, which is what makes it worth guarding. Nobody reports a missing typecheck; the app builds, deploys and works. You find out months later, when a rename that should have failed in nine files failed in none of them, and the type annotations turn out to have been decorative for some time.

Next

The Basic Types — and, more usefully, which annotations you should not bother writing.