A reliable TypeScript project setup makes the compiler, editor, test runner, bundler, and production build agree. This guide provides a reusable checklist for choosing a folder structure, writing a practical tsconfig.json, defining package scripts, and avoiding the module-resolution mismatches that cause confusing errors.
Overview
TypeScript setup is less about finding one perfect configuration and more about making a few explicit decisions. First, decide what the project runs in: Node.js, a browser, a framework-managed application, or a package consumed by other projects. Next, decide whether TypeScript should emit JavaScript or only check types. Finally, make sure the commands used locally and in CI perform the same checks.
A small application can start with one src directory and a single configuration file. A library needs additional attention because its output, declaration files, module formats, and public entry points become part of its API. A framework project may already provide compiler and bundler defaults, so extending or overriding those settings should be deliberate.
Keep the project configuration close to the repository root unless there is a clear reason to use multiple configurations. A useful baseline structure looks like this:
project/
├── src/
│ ├── index.ts
│ └── feature/
├── test/ # or tests/
├── dist/ # generated output; usually ignored by Git
├── package.json
├── tsconfig.json
├── tsconfig.build.json # optional: production or library build
├── .gitignore
└── README.md
Do not create every possible directory on day one. Add folders when they represent a real boundary, such as application code, tests, scripts, generated files, or shared configuration.
Checklist by scenario
1. Node.js application or API
- Set
rootDirto the source directory andoutDirto a generated directory such asdist. - Choose an explicit module strategy that matches the package configuration and the Node.js runtime target.
- Keep runtime code in
src/; place migration, seed, or maintenance scripts in a clearly named directory if they are compiled separately. - Use a type-check command that does not emit files, especially in development and CI.
- Make the production command build first and execute the generated JavaScript rather than relying on a development transpiler.
2. Browser application or Vite-style project
- Let the bundler handle application output unless the project has a specific reason to invoke
tscfor emission. - Use
noEmitin the application type-check configuration when the bundler owns transpilation. - Include browser types and the source files required by the framework or bundler, but avoid adding broad ambient types without checking for name collisions.
- Keep path aliases synchronized with the bundler, test runner, and editor; a TypeScript alias alone does not change runtime resolution.
3. Reusable TypeScript library
- Separate source files from generated JavaScript and declaration output.
- Decide which module formats and runtime targets the package will publish before writing build scripts.
- Enable declaration generation if consumers need types, and verify that the package entry fields point to files that actually exist after the build.
- Use an explicit public entry point such as
src/index.tsrather than exposing internal folder paths accidentally. - Test the packed or published shape of the package, not only imports from the local source tree.
4. Migrating an existing JavaScript project
- Start with a narrow
includelist so unrelated scripts and generated files do not enter the first type-check. - Use gradual strictness only as a temporary migration plan. Record which checks are postponed and create follow-up tasks.
- Turn on checking for JavaScript files only when the team is ready to address the resulting diagnostics.
- Convert boundaries first: API responses, configuration loading, shared utilities, and exported functions benefit from clear types early.
Module-resolution decision table
| Project shape | Choose first | Double-check |
|---|---|---|
| Node application | Match TypeScript modules to the package's runtime module mode | File extensions, package metadata, and how compiled files are executed |
| Bundled browser app | Use the bundler's documented resolution model | Aliases, asset imports, test-runner resolution, and editor behavior |
| Published library | Design output formats and package entry points together | Declaration paths, exports, consumer compatibility, and package contents |
| Mixed or legacy codebase | Preserve the working runtime first, then migrate deliberately | Interop flags, default imports, and differences between source and emitted code |
A practical tsconfig baseline
The following configuration is a starting point for a strict Node-style application that emits compiled JavaScript. It is not a universal preset; adjust the module and library settings to the actual runtime.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmitOnError": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
target: Sets the JavaScript language level TypeScript emits. Choose a target supported by the environments you actually deploy to. A newer target can reduce unnecessary down-level transformation, while an older target may improve compatibility.
module and moduleResolution: These must reflect how imports are interpreted and how packages are found. For Node projects, use the Node-aware option appropriate to the project's package metadata and runtime. For bundler-managed applications, use the module settings recommended by the bundler rather than copying a Node configuration unchanged.
strict: Enables the family of strict type-checking options. It catches many mistakes at function boundaries, nullable values, and inferred types. If a legacy migration cannot enable it immediately, document the temporary exceptions instead of treating a permanently loose configuration as the end state.
rootDir and outDir: Define the relationship between source and generated files. Keeping generated output outside the source tree prevents compiled files from being imported accidentally and makes cleanup predictable.
noEmit: Use this for projects where Vite, another bundler, or a separate build tool emits JavaScript. A common pattern is a development configuration with noEmit: true and a dedicated build configuration when TypeScript must produce package or server output.
declaration: Usually matters for libraries, not ordinary applications. It produces type declaration files that describe the public TypeScript API.
sourceMap: Helps map runtime errors back to source files during debugging. Confirm that your deployment process handles map files according to your code-disclosure requirements.
skipLibCheck: Skips type-checking of declaration files in dependencies. It can reduce noise and build time, but it does not fix incompatible application types. Treat it as a dependency-checking tradeoff, not a replacement for resolving your own diagnostics.
Package.json scripts that keep workflows aligned
Script names are less important than consistent responsibilities. A small project can use this pattern:
{
"scripts": {
"dev": "your-dev-command",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/index.js",
"test": "your-test-command",
"clean": "your-clean-command"
}
}
For a bundler-managed frontend, build may run the bundler while typecheck runs tsc --noEmit. For a library, build may generate JavaScript and declarations. The important rule is that CI should call the same type-check, test, and build scripts developers use locally.
Choose one package manager for the repository and commit its lockfile. If the team is comparing npm, pnpm, and Yarn, evaluate workspace behavior, lockfile policy, CI installation, and the commands contributors will actually use. See the npm vs pnpm vs Yarn comparison for a broader workflow checklist.
What to double-check
- Runtime versus compiler: TypeScript can accept an import that your runtime or bundler cannot resolve. Test the built application, not only the editor.
- Package metadata: For Node applications and libraries, compare the package's module declaration, entry fields, and file extensions with the emitted output.
- Tests: Ensure the test runner uses the same aliases, module interpretation, and environment assumptions as the application.
- Generated files: Add
dist, coverage output, and other generated directories to the ignore rules unless the project intentionally versions them. - Editor diagnostics: Confirm the editor is using the repository's TypeScript configuration rather than a parent configuration or a global default.
- Environment types: Browser, Node, test, and framework globals should be included intentionally. Extra global types can hide missing imports or create conflicts.
When a build error appears only in CI or production, compare the exact commands, Node version range, environment variables, lockfile, and clean-install behavior. The same debugging discipline used for API failures—isolating the failing layer and checking the actual request or output—also helps with build failures. For related debugging workflows, see the guides to HTTP status codes and the Chrome DevTools Network tab.
Common mistakes
- Copying a framework configuration into a Node project. Browser bundlers and Node runtimes resolve modules differently. Start with the environment, not a popular snippet.
- Using path aliases without configuring the runtime. The editor may understand
@/utilswhile Node or the test runner does not. Configure every consumer or avoid the alias. - Compiling files in place. Emitting JavaScript beside source files makes imports, cleanup, and version control harder to reason about.
- Running only a transpiler. Fast transpilation can remove types without detecting type errors. Keep a separate, explicit type-check step.
- Using
anyto silence migration errors. Prefer a narrow type, an unknown value with runtime validation, or a documented temporary boundary. - Assuming
esModuleInteropfixes every import problem. Interoperability settings affect type interpretation and emitted code, but they cannot make incompatible package metadata or runtime behavior disappear. - Allowing configuration drift. If local development, tests, and CI use different commands or configurations, failures become difficult to reproduce.
When to revisit
Review this setup before starting a new application, publishing a library, migrating from JavaScript, or changing the Node.js or frontend build tool. It is also worth revisiting during seasonal planning cycles when dependencies, supported runtimes, or deployment targets are reviewed.
Run this short checklist whenever workflows or tools change:
- Record the supported runtime and the command used to start production code.
- Confirm that
module,moduleResolution, package metadata, and emitted file extensions agree. - Run type-checking, tests, and a clean production build from a fresh install.
- Inspect the generated directory and verify that package entry points reference real files.
- Open the project in a clean editor session and confirm that diagnostics match CI.
- Update the README with setup commands, required environment variables, and the expected package-manager workflow.
Keeping these decisions explicit turns TypeScript configuration from a collection of copied options into a maintainable part of the build workflow. When a mismatch appears, check the runtime, compiler, bundler, and package metadata as separate layers; then change the smallest layer that is actually responsible.