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
mkdir my-ts-project && cd my-ts-projectnpm init -ynpm install --save-dev typescript @types/nodenpx 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": truestrict: 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: truefunction 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: falsefunction 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": trueWithout 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: falseimport * as express from 'express';
// esModuleInterop: true โ matches what most developers actually expectimport 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
| Option | What it controls | Safe default |
|---|---|---|
target | Output JS syntax version | Match your actual runtime (Node LTS, modern browsers) |
module / moduleResolution | How imports resolve | NodeNext for Node.js projects, bundler for frontend projects using Vite/webpack |
strict | Bundles all major safety checks | true, always, from project start |
outDir / rootDir | Where compiled output goes / where source lives | dist / src, keeping them separate |
declaration | Emit .d.ts files alongside compiled JS | true if youโre publishing a library others will import |
sourceMap | Emit .map files for debugging compiled output | true in development |
noUnusedLocals / noUnusedParameters | Flag unused variables/params | true โ catches dead code the compiler can see but you might miss |
Running TypeScript: Four Real Options
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" }}npm run typecheck # runs the full type checker, emits nothing, just reports errors โ exit code 1 on failuretsc --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
| Symptom | Usual cause |
|---|---|
| โCannot find module โfsโโ (or any Node built-in) | Missing @types/node |
| โCannot use import statement outside a moduleโ at runtime | module in tsconfig doesnโt match your package.jsonโs "type" field |
Type errors donโt block npm run dev | Using tsx/ts-node in transpile-only mode โ type-check separately in CI |
Editor shows different errors than tsc on the command line | Editor 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 TypeScript | A 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.