TypeScript Configuration for Large Projects
As codebases grow, a thoughtful TypeScript configuration becomes the backbone of maintainability and developer velocity.

TypeScript is fantastic at catching bugs early, but in a large monorepo or a multi-service frontend, the default settings start to fray. Compilation slows down. Imports get messy. Package boundaries blur. And subtle configuration choices cause type checking to pass locally but fail on CI. I have learned this the hard way after inheriting a sprawling React app where tsc took nearly two minutes to finish and every teammate had a different editor experience.
In this post, I will share a practical approach to configuring TypeScript for large projects. We will look at realistic project structures, configuration files, and patterns that keep builds fast and types accurate. We will talk about monorepo strategies, incremental builds, path mapping, ambient types, and how to set guardrails without suffocating the team. There is no magic bullet, but there are solid patterns that pay off as your project grows.
Where TypeScript fits today
TypeScript has become the de facto typed language for modern web development. It sits at the center of frontend frameworks like React, Vue, and Angular, and increasingly powers backend services on Node.js and even edge runtimes. Many teams adopt it because it improves readability, reduces runtime surprises, and provides better editor tooling.
Compared to plain JavaScript, TypeScript trades a small upfront learning curve for long-term maintainability. Compared to compiled languages like Go or Rust, TypeScript is dynamically typed at runtime but statically checked at compile time, which makes it ideal for UI development where iteration speed matters. In larger organizations, TypeScript helps teams collaborate without stepping on each other’s toes by enforcing clear contracts between modules and services.
Who typically uses it:
- Frontend teams building complex UIs with React, Vue, or Angular
- Backend teams building typed APIs with Node.js, NestJS, or tRPC
- Platform teams maintaining shared libraries and design systems
- Monorepo teams coordinating multiple packages with tools like Nx or Turborepo
The reality of large TypeScript projects
A large TypeScript project is not just “more files.” It is:
- Multiple tsconfig.json files across packages and apps
- Strictness levels that evolve over time
- Path mapping for clean imports across package boundaries
- Build orchestration that might include bundlers, transpilers, and test runners
- Ambient types for third-party libraries and internal packages
- CI pipelines that need to be fast and reliable
I remember onboarding to a project with a single tsconfig at the repo root. It worked at first, but the build was slow and the editor lagged. The turning point was splitting the configuration, adopting incremental compilation, and clarifying module boundaries. The improvements were not dramatic in a single PR, but over a month, CI times dropped by 40% and the editor was responsive again.
Core concepts and practical configuration
tsconfig.json hierarchy and project references
For large projects, you rarely rely on a single tsconfig. You create multiple configs for apps and packages, and use project references to coordinate builds. Project references enable TypeScript to build only what changed and enforce boundaries between packages.
Folder structure with tsconfig files:
apps/
web/
tsconfig.json
tsconfig.build.json
src/
index.ts
server/
tsconfig.json
tsconfig.build.json
src/
index.ts
packages/
shared/
tsconfig.json
tsconfig.build.json
src/
index.ts
utils.ts
ui/
tsconfig.json
tsconfig.build.json
src/
Button.tsx
tooling/
tsconfig.base.json
A base tsconfig (tooling/tsconfig.base.json) defines common compiler options. Apps and packages extend it.
tooling/tsconfig.base.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "../dist",
"rootDir": "../src",
"types": ["node", "jest"],
"baseUrl": "../src",
"paths": {}
},
"exclude": ["node_modules", "dist", "coverage"]
}
Then reference the base in each package. packages/shared/tsconfig.json:
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"paths": {
"@myorg/shared": ["./src/index.ts"],
"@myorg/shared/*": ["./src/*"]
}
},
"references": [],
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
For apps that depend on packages, you add references. apps/web/tsconfig.json:
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx"
},
"references": [
{ "path": "../../packages/shared" },
{ "path": "../../packages/ui" }
],
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
This structure supports:
- Selective builds via
tsc -b(build mode) - Clear ownership of types per package
- Faster IDE performance by narrowing the project scope
Incremental and composite builds
In large repositories, compiling everything from scratch is wasteful. TypeScript’s incremental and composite flags enable reuse of prior build artifacts.
Key points:
incremental: truestores build info in a .tsbuildinfo file, reducing rebuild timescomposite: truemakes a project referenceable by others, enabling dependency graph builds- Use
tsc -bortsc --buildto build a project and its dependencies
Example command flow:
# Clean and build the web app and its dependencies
cd apps/web
tsc -b
# Rebuild only changed files
tsc -b --incremental
# Watch mode for development
tsc -b --watch
In CI, cache .tsbuildinfo and dist folders across runs to minimize work. If you use a build orchestrator like Nx or Turborepo, wire TypeScript’s build into the pipeline so that only impacted packages recompile.
Path mapping and module resolution
Path mapping makes imports clean but can complicate module resolution. With monorepos, you often map internal packages to short aliases.
Example path mapping in packages/ui/tsconfig.json:
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx",
"paths": {
"@myorg/ui": ["./src/index.ts"],
"@myorg/ui/*": ["./src/*"],
"@myorg/shared": ["../../packages/shared/src/index.ts"]
}
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"]
}
Source import in apps/web/src/components/App.tsx:
import { Button } from '@myorg/ui';
import { formatDate } from '@myorg/shared';
export function App() {
return (
<div>
<Button onClick={() => console.log(formatDate(new Date()))}>
Hello
</Button>
</div>
);
}
For bundlers like Vite or Webpack, ensure aliases match TypeScript paths to avoid dual configuration drift.
vite.config.ts example:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@myorg/ui': path.resolve(__dirname, '../packages/ui/src/index.ts'),
'@myorg/shared': path.resolve(__dirname, '../packages/shared/src/index.ts'),
},
},
});
A small tip: prefer relative imports within a package, and only use path aliases for cross-package imports. This keeps internal coupling visible.
Strictness strategy and gradual adoption
Turning on every strict flag at once can be overwhelming in legacy codebases. A pragmatic approach is to enable strict gradually, package by package or directory by directory.
Baseline strict flags I recommend from day one:
strict: true(enables all strict flags)noImplicitAny: truestrictNullChecks: truestrictFunctionTypes: truestrictBindCallApply: trueexactOptionalPropertyTypes: true(only if you can handle the stricter checks)noUncheckedIndexedAccess: true(useful for array operations)
If a package is not ready, you can lower strictness per tsconfig and increase it over time:
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"strict": false,
"noImplicitAny": true,
"strictNullChecks": true
}
}
This lets the team keep moving while preventing regressions in strict areas.
Ambient types and third-party libraries
Many libraries ship with types, but not all. For libraries without types, add ambient declarations under a types directory.
Example: packages/shared/types/global.d.ts:
declare module 'some-legacy-library' {
export function doWork(input: string): Promise<void>;
}
If you maintain internal packages, publish declaration files or include a types field in package.json:
{
"name": "@myorg/shared",
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
For global augmentations, such as extending Express Request types, use module augmentation:
// packages/server/types/augment.d.ts
import 'express';
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
ts-node, tsx, and runtime development
For Node.js tools or scripts, avoid running raw ts-node in large projects; it often conflicts with path mapping and swc-based transpilation. Instead, use tsx for faster local execution:
# Run a script with tsx
npx tsx tools/migrate.ts
In CI or production, compile to JavaScript first with tsc -b and run the emitted code.
Testing with Jest or Vitest
When testing TypeScript, configure the test runner to handle paths and tsconfig. For Jest, use ts-jest or swc to accelerate.
Example Jest config (jest.config.js) with path mapping:
const { pathsToModuleNameMapper } = require('ts-jest/utils');
const { compilerOptions } = require('./tooling/tsconfig.base.json');
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
roots: ['<rootDir>/apps', '<rootDir>/packages'],
};
For Vitest, it often works out of the box with tsconfig, but ensure alias resolution matches.
vite.config.ts (for Vitest):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@myorg/ui': path.resolve(__dirname, '../packages/ui/src/index.ts'),
'@myorg/shared': path.resolve(__dirname, '../packages/shared/src/index.ts'),
},
},
test: {
globals: true,
environment: 'jsdom',
},
});
Editor and contributor experience
Large projects benefit from editor-level TypeScript settings that prevent inconsistent behavior across machines. A .vscode/settings.json file in the repo helps align teams:
{
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.suggest.autoImports": true,
"typescript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
Encourage teammates to use the workspace version of TypeScript in VS Code via the “TypeScript: Select TypeScript Version” command. This avoids mismatches between local global installs and project needs.
Real-world patterns: a monorepo example
Let’s walk through a realistic setup. You are building a web app and a server, plus a shared UI library and a utility package. Each package owns its types, tests, and builds.
Project layout:
apps/
web/
tsconfig.json
tsconfig.build.json
vite.config.ts
src/
index.tsx
package.json
server/
tsconfig.json
tsconfig.build.json
src/
index.ts
package.json
packages/
shared/
tsconfig.json
tsconfig.build.json
src/
index.ts
utils.ts
package.json
ui/
tsconfig.json
tsconfig.build.json
src/
Button.tsx
index.ts
package.json
tooling/
tsconfig.base.json
apps/web/tsconfig.build.json (for CI builds with emit):
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"sourceMap": false
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
}
apps/web/tsconfig.json (for IDE and type checking only):
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"],
"baseUrl": "src"
},
"references": [
{ "path": "../../packages/ui" },
{ "path": "../../packages/shared" }
],
"include": ["src/**/*"]
}
When you run tsc -b apps/web/tsconfig.build.json, TypeScript builds the web app and its dependencies, caching incremental artifacts.
For shared UI components, ensure they export types cleanly and do not leak peer dependencies unexpectedly.
packages/ui/src/Button.tsx:
import React from 'react';
export interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
variant?: 'primary' | 'secondary';
}
export const Button: React.FC<ButtonProps> = ({
children,
onClick,
variant = 'primary',
}) => {
const styles =
variant === 'primary'
? { background: '#2563eb', color: '#fff' }
: { background: '#e5e7eb', color: '#111827' };
return (
<button style={styles} onClick={onClick}>
{children}
</button>
);
};
For server code, keep tsconfig strict and avoid mixing ESM and CJS unless necessary.
apps/server/tsconfig.json:
{
"extends": "../../tooling/tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"target": "ES2020",
"lib": ["ES2020"],
"outDir": "dist",
"rootDir": "src"
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"]
}
Strengths, weaknesses, and tradeoffs
Strengths:
- Strong editor tooling that improves discoverability
- Clear contracts between modules via typed interfaces
- Incremental builds reduce CI times in large repos
- Path mapping improves developer ergonomics in monorepos
- Flexible strictness lets teams adopt gradually
Weaknesses:
- Configuration can become complex, especially with multiple tsconfigs
- Path aliasing can cause conflicts if not aligned with bundlers
- Strictness drift leads to inconsistent type safety across packages
- Build times can still be long in massive projects without incrementalization
- ESM vs CommonJS confusion can bite when mixing Node versions
When TypeScript is a good choice:
- You need high collaboration across multiple teams
- You want to prevent runtime errors by catching issues at compile time
- You are using frameworks and tools with strong TypeScript support
- Your team values editor experience and refactoring safety
When you might skip or limit TypeScript:
- A tiny prototype where speed matters more than types
- A purely server-side project with a small surface area and fast iteration
- A project heavily reliant on dynamic runtime behavior where types become noise
- A legacy codebase that cannot afford a migration and does not evolve
Personal experience: what actually mattered
In one large React app, we introduced TypeScript incrementally. We started with noImplicitAny and strictNullChecks in the “new features” folder. We used JSDoc for the rest. Over time, we migrated modules one by one. The key learning was that partial strictness is better than no strictness, and that boundaries matter: if a package has clean types and narrow interfaces, the rest of the app benefits even if internal code is not fully typed.
Another time, path mapping broke our Jest tests because the test runner could not resolve aliases. We fixed it by sharing the mapping logic between Jest and Vite, and by keeping the mapping minimal. When we tried to map every internal folder, we created a maintenance headache. When we mapped only the public entry points of packages, the mental model improved.
A common mistake I see is treating tsconfig.json as a single global file. Splitting into base, app, and package configs is worth the extra file because it lets teams move independently and improves IDE performance. Another mistake is forgetting to publish declaration files for internal packages. Without types in package.json, consuming apps fail with “cannot find module” errors at compile time.
Finally, using tsc -b in CI and caching artifacts cut our CI time from 12 minutes to 7 minutes. It did not happen overnight, and it required tuning caching keys, but it was worth it.
Getting started: workflow and mental models
Start with a base tsconfig that is reasonably strict and includes the most impactful flags. Create one tsconfig per app or package. Use project references for cross-package dependencies. In development, prefer tsc -b --watch for type checking and let your bundler handle transforms. In CI, run a full build with tsc -b and cache .tsbuildinfo and dist folders.
Workflow overview:
- Define tooling/tsconfig.base.json with shared options
- For each package, create tsconfig.json (noEmit) and tsconfig.build.json (emit)
- For each app, create tsconfig.json (noEmit) and tsconfig.build.json (emit)
- Reference dependent packages in tsconfig.json via “references”
- Align bundler aliases with TypeScript paths
- Use tsx for local script development, and tsc -b for production builds
- Add ambient types in a types directory and reference them via “types” in tsconfig
- Integrate Jest or Vitest with path mapping
- Share editor settings to ensure consistent developer experience
Mental model:
- TypeScript projects are graphs of packages and apps, not isolated files
- Type checking is separate from bundling; both must be configured
- Strictness is a team policy; make it incremental and explicit
- Performance is a feature; incremental builds and selective compilation matter
Free learning resources
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/ The canonical reference covering configuration, tsconfig options, and advanced patterns.
- TypeScript Project References: https://www.typescriptlang.org/docs/handbook/project-references.html Practical guide to composite projects and incremental builds.
- tsconfig.json options: https://www.typescriptlang.org/tsconfig A clear breakdown of each compiler option with examples.
- Vite TypeScript Setup: https://vitejs.dev/guide/ Official guide to configure Vite with TypeScript and aliases.
- Vitest TypeScript Guide: https://vitest.dev/guide/ Using Vitest with TypeScript, including tsconfig integration.
- Jest with TypeScript: https://jestjs.io/docs/getting-started#using-typescript Basic setup for Jest and ts-jest.
These resources are excellent starting points. I revisit the handbook regularly, especially when adding new flags like noUncheckedIndexedAccess or adjusting path mapping.
Who should use TypeScript and who might skip it
You should consider TypeScript if:
- You work in a team where clear contracts reduce coordination overhead
- Your application has a large surface area and frequent refactors
- You value editor tooling and want to reduce onboarding time for new engineers
- You are building libraries that will be consumed by multiple apps
You might skip or postpone TypeScript if:
- The project is tiny and likely to be thrown away
- You rely heavily on dynamic runtime behavior and types become noise
- Your team does not have bandwidth to invest in configuration and build tooling
- Your stack does not have first-class TypeScript support or documentation
Summary
TypeScript configuration in large projects is about managing complexity, not just checking types. Splitting tsconfig files, adopting incremental builds, mapping paths carefully, and setting strictness gradually helps keep compilation fast and developer experience smooth. The real-world impact is fewer runtime surprises, faster CI, and a codebase that is easier to navigate and refactor.
If you are starting a new large project, invest in tsconfig structure from day one. If you are inheriting a monolith, create a base config and migrate package by package. Either way, treat configuration as a living document that evolves with your project.
References:
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/
- TypeScript Project References: https://www.typescriptlang.org/docs/handbook/project-references.html
- tsconfig.json options: https://www.typescriptlang.org/tsconfig
- Vite TypeScript Setup: https://vitejs.dev/guide/
- Vitest TypeScript Guide: https://vitest.dev/guide/
- Jest with TypeScript: https://jestjs.io/docs/getting-started#using-typescript




