Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

Setting Up a TypeScript Project: tsconfig.json, Strict Mode & Build Tools

A single tsc hello.ts command is enough to learn the language, but itโ€™s not how any real project runs TypeScript. Real projects have a tsconfig.json controlling dozens of compiler behaviors, a decision about which tool actually runs the code, and โ€” critically โ€” an opinion on strict mode that determines how much of TypeScriptโ€™s safety youโ€™re actually getting.

This guide sets up a project the way youโ€™d actually maintain one: a tsconfig.json you understand line by line, and a clear picture of when to reach for tsc, ts-node, tsx, or a bundler.


Starting a Project

Terminal window
mkdir my-ts-project && cd my-ts-project
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

@types/node deserves a mention on its own: itโ€™s not TypeScript itself, itโ€™s a package of type declarations for Node.jsโ€™s built-in modules (fs, path, http). Without it, import fs from 'fs' type-checks fs as any โ€” TypeScript doesnโ€™t ship knowledge of Nodeโ€™s APIs, so a separate @types/* package (from the community-maintained DefinitelyTyped project) supplies it. This pattern โ€” @types/<package-name> โ€” is how thousands of untyped JavaScript libraries get TypeScript support without their maintainers writing a line of TypeScript.

npx tsc --init generates a tsconfig.json with every option listed and commented out, which is useful for browsing but overwhelming as a starting point. Hereโ€™s a trimmed, realistic one:

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

The Options That Actually Matter

target โ€” which JavaScript version comes out the other end

"target": "ES2022"

target controls what syntax tsc is allowed to emit. Write an async function with target: "ES5" and the compiler rewrites it into verbose generator-based polyfill code so it runs on ancient engines; write the same function with target: "ES2022" and it passes through nearly unchanged, because modern engines (and modern Node.js) already support async/await natively. Targeting a version newer than your actual deployment environment supports is a real, silent-until-runtime failure mode โ€” if you ship target: "ES2022" output to a runtime that predates it, features like top-level await simply donโ€™t exist there.

module and moduleResolution โ€” how imports get resolved

"module": "NodeNext",
"moduleResolution": "NodeNext"

This pair tells TypeScript to follow Node.jsโ€™s actual module resolution algorithm (respecting package.jsonโ€™s "type": "module" field, .mjs/.cjs extensions, and export maps) rather than an older, looser algorithm. Get this mismatched with your actual runtimeโ€™s module system and youโ€™ll see the confusing โ€œCannot use import statement outside a moduleโ€ error at runtime โ€” even though tsc compiled without complaint, because compilation and module resolution are checked somewhat independently.

strict โ€” the single flag that changes everything

"strict": true

strict: true is shorthand for enabling a bundle of separate checks simultaneously โ€” most importantly strictNullChecks, noImplicitAny, and strictFunctionTypes. Without it, TypeScriptโ€™s type checking is dramatically weaker than most tutorials assume:

// strict: false (or unset โ€” false is the historical default)
function getUser(id: number) {
if (id === 0) return null;
return { id, name: "Alice" };
}
const user = getUser(1);
console.log(user.name); // No error! TypeScript doesn't know user could be null here
// strict: true
function getUser(id: number) {
if (id === 0) return null;
return { id, name: "Alice" };
}
const user = getUser(1);
console.log(user.name); // Error: 'user' is possibly 'null'

This single difference โ€” strictNullChecks โ€” is arguably the most valuable thing TypeScript does, and itโ€™s off unless strict is explicitly enabled. A huge share of โ€œTypeScript didnโ€™t catch this obvious bugโ€ complaints trace back to a tsconfig.json that never turned strict mode on. New projects should enable strict: true from day one; the cost of retrofitting it onto a large codebase later (fixing every now-flagged nullable access) is real and grows with the codebaseโ€™s size.

noImplicitAny โ€” refusing to silently give up

// noImplicitAny: false
function process(data) { // data is implicitly 'any' โ€” no error, but also no type safety at all
return data.whatever.deeply.nested;
}
// noImplicitAny: true (included in strict: true)
function process(data) { // Error: Parameter 'data' implicitly has an 'any' type
return data.whatever.deeply.nested;
}

any is TypeScriptโ€™s escape hatch โ€” a value typed any gets zero checking, silently, for every operation performed on it. noImplicitAny doesnโ€™t ban any outright; it bans TypeScript from silently defaulting to it when it canโ€™t infer a type, forcing you to write data: any explicitly if you really mean it. That explicitness is the whole point โ€” an accidental any from a missing annotation is invisible until it causes a runtime crash somewhere far from where the mistake was made.

skipLibCheck โ€” a pragmatic performance trade-off

"skipLibCheck": true

Without this, tsc type-checks every .d.ts declaration file pulled in from node_modules โ€” including ones from unrelated packages, some of which have genuinely broken or conflicting type definitions. skipLibCheck: true skips checking library type files and only checks your own source, which is both faster and avoids being blocked by a bug in someone elseโ€™s published types. The trade-off: it also means you wonโ€™t be warned if two of your dependencies ship genuinely incompatible type declarations for the same global โ€” rare in practice, and the default recommendation almost everywhere for this reason.

esModuleInterop โ€” smoothing over CommonJS/ES module friction

// esModuleInterop: false
import * as express from 'express';
// esModuleInterop: true โ€” matches what most developers actually expect
import express from 'express';

Nodeโ€™s module ecosystem has spent years straddling CommonJS (require/module.exports) and ES modules (import/export). esModuleInterop makes TypeScriptโ€™s handling of default imports from CommonJS packages behave the way developers coming from Babel or plain JavaScript already expect โ€” without it, importing a CommonJS default export forces the more awkward import * as x syntax above.


tsconfig Options at a Glance

OptionWhat it controlsSafe default
targetOutput JS syntax versionMatch your actual runtime (Node LTS, modern browsers)
module / moduleResolutionHow imports resolveNodeNext for Node.js projects, bundler for frontend projects using Vite/webpack
strictBundles all major safety checkstrue, always, from project start
outDir / rootDirWhere compiled output goes / where source livesdist / src, keeping them separate
declarationEmit .d.ts files alongside compiled JStrue if youโ€™re publishing a library others will import
sourceMapEmit .map files for debugging compiled outputtrue in development
noUnusedLocals / noUnusedParametersFlag unused variables/paramstrue โ€” catches dead code the compiler can see but you might miss

Running TypeScript: Four Real Options

Production build

Quick scripts, dev iteration

Frontend app

Type-check only, no output

Write .ts files

How do you run them?

tsc โ†’ plain .js โ†’ node

tsx or ts-node

(compile in memory, run immediately)

Vite/webpack

(bundles + transpiles, usually via esbuild/swc)

tsc --noEmit

(CI type-checking gate)

tsc (compile, then run separately) โ€” the ground-truth approach. Slower iteration loop (edit โ†’ compile โ†’ run), but itโ€™s exactly what production deployments do: compile once during the build, ship plain JavaScript, never touch tsc again at runtime.

tsx โ€” the modern successor to ts-node. Uses esbuild under the hood to transpile TypeScript to JavaScript in memory, without type-checking, and runs it immediately. Fast enough for a tight development loop (tsx watch src/index.ts), but because it skips type-checking entirely, it will happily run code with type errors in it โ€” pair it with a separate tsc --noEmit step in CI to actually catch those.

ts-node โ€” the older tool doing the same job, optionally with full type-checking enabled (slower, but catches errors tsx wonโ€™t by default). Largely superseded by tsx for day-to-day development, but still common in existing projects and documentation.

Bundlers (Vite, webpack, esbuild, swc) โ€” for frontend applications, these tools transpile TypeScript as part of building your JS bundle, almost always via esbuild or swc rather than tsc itself, because both are dramatically faster at transpilation (they skip full type-checking, trading it for speed). This is why a Vite dev server can hot-reload a TypeScript file in milliseconds โ€” it isnโ€™t running the TypeScript compilerโ€™s type checker at all, just stripping types syntactically.


Type-Checking as a Separate CI Step

Because fast tools (tsx, esbuild, swc) skip type-checking to stay fast, a production pipeline typically adds it back as an explicit gate:

{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
}
}
Terminal window
npm run typecheck # runs the full type checker, emits nothing, just reports errors โ€” exit code 1 on failure

tsc --noEmit is the command CI pipelines run to fail a build on type errors without needing the compiled output at all โ€” a lightweight, purpose-built check separate from the actual build step, and one of the most common package.json scripts youโ€™ll find in any serious TypeScript repository.


A Minimal Project Structure That Scales

my-ts-project/
โ”œโ”€โ”€ src/
โ”‚ โ”œโ”€โ”€ index.ts
โ”‚ โ””โ”€โ”€ utils/
โ”‚ โ””โ”€โ”€ format.ts
โ”œโ”€โ”€ dist/ โ† generated by tsc, gitignored
โ”œโ”€โ”€ tsconfig.json
โ”œโ”€โ”€ package.json
โ””โ”€โ”€ .gitignore โ† must include "dist" and "node_modules"
// package.json scripts, a realistic starting point
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}

Keeping src and dist separate (via rootDir/outDir) rather than compiling files in place is worth the small extra config โ€” it makes .gitignore-ing generated output trivial, and it means nobody accidentally hand-edits a compiled .js file thinking itโ€™s the source.


Common Setup Mistakes

SymptomUsual cause
โ€Cannot find module โ€˜fsโ€™โ€ (or any Node built-in)Missing @types/node
โ€Cannot use import statement outside a moduleโ€ at runtimemodule in tsconfig doesnโ€™t match your package.jsonโ€™s "type" field
Type errors donโ€™t block npm run devUsing tsx/ts-node in transpile-only mode โ€” type-check separately in CI
Editor shows different errors than tsc on the command lineEditor is using a different (often older, globally-installed) TypeScript version than the projectโ€™s node_modules copy
strict errors appear โ€œout of nowhereโ€ after upgrading TypeScriptA new TypeScript version added new checks under strict โ€” read the release notes before upgrading a large codebase

Frequently Asked Questions

Do I need tsconfig.json for a tiny script? Not strictly โ€” tsc hello.ts works standalone. But the moment you have more than one file, or care about module resolution, strict mode, or output location, a config file removes ambiguity that would otherwise need repeating as command-line flags every time.

Should I commit dist/ to git? No, for application code โ€” itโ€™s generated, reproducible output, and committing it just creates merge conflicts and staleness risk. Libraries published to npm are the exception: consumers need the compiled dist/ folder (and ideally .d.ts files), so itโ€™s typically built as a release step, not committed to the main branch history.

Whatโ€™s the difference between tsc --noEmit and just running tsc? tsc alone type-checks and writes compiled .js files to outDir. --noEmit runs the identical type-checking pass but writes nothing โ€” purely a validation step, which is why itโ€™s the natural choice for a CI gate that shouldnโ€™t produce build artifacts.

Can I gradually add TypeScript to an existing JavaScript project? Yes โ€” this is one of TypeScriptโ€™s core design goals. Start with allowJs: true and checkJs: false in tsconfig.json, rename files to .ts one at a time, and turn on strict only once most of the codebase is converted. Trying to enable strict: true on day one of a large migration usually produces an overwhelming wall of errors that stalls the whole effort.


Whatโ€™s Next

With a real tsconfig.json and a build pipeline in place, youโ€™re set up the way production TypeScript projects actually work โ€” not just running single files through tsc. Next: Basic Types in TypeScript, where we cover the type vocabulary โ€” primitives, arrays, tuples, and the crucial difference between any, unknown, and never โ€” that everything else in this series builds on.