- Published on
Optimizing Typescript Performance: Tips and Techniques
Your TypeScript build just took four minutes to complete. Again. While your teammates are grabbing their third coffee, you're watching the compiler crawl through your codebase like it's parsing the entire internet. Sound familiar?
Here's the thing: TypeScript's type checking is incredibly powerful, but it can also be a performance bottleneck if you're not careful. I've seen teams abandon TypeScript altogether because their build times became unbearable, which is like throwing away a Ferrari because you don't know how to change the oil.
Let's fix this.
The Type System Isn't Free — Understanding the Real Cost
TypeScript's type checker does serious work behind the scenes. Every time you write const user: User = getUser(), the compiler validates that getUser() actually returns something compatible with the User type. Multiply this by thousands of operations across a large codebase, and you start to feel the burn.
The biggest performance killer? Complex union types and excessive type instantiation. Here's what kills performance:
// This creates exponential complexity
type BadUnion =
| { type: 'A'; data: string }
| { type: 'B'; data: number }
| { type: 'C'; data: boolean }
| { type: 'D'; data: object }
// ... 20 more variants
// The compiler has to check all possibilities for every operation
function processData(item: BadUnion) {
// TypeScript works overtime here
return item.data;
}
Instead, use discriminated unions strategically:
// Much better - explicit discrimination
type Action =
| { kind: 'user_action'; userId: string; action: string }
| { kind: 'system_event'; eventType: string; timestamp: number };
function handleAction(action: Action) {
switch (action.kind) {
case 'user_action':
// TypeScript knows exactly what this is
return `User ${action.userId} did ${action.action}`;
case 'system_event':
return `System event: ${action.eventType}`;
}
}
The switch statement gives TypeScript a clear path through your types, eliminating the need to check every possibility at every step.
Compiler Configuration: Your First Line of Defense
Most developers treat tsconfig.json like a set-and-forget configuration file. That's a mistake. The right compiler options can cut your build time in half.
Enable incremental compilation — this is probably the single biggest win you can get:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
This creates a cache file that tracks what's changed between builds. On subsequent builds, TypeScript only re-checks the files that actually changed and their dependencies.
Use project references for large codebases:
{
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/ui" },
{ "path": "./packages/api" }
]
}
Project references let TypeScript build parts of your codebase independently. If you only change the UI package, the core and API packages don't need to be re-compiled.
Skip lib checking if you trust your dependencies:
{
"compilerOptions": {
"skipLibCheck": true
}
}
This tells TypeScript to skip type checking of declaration files (.d.ts). Since you probably didn't write these files and they're likely correct, this saves significant time with minimal risk.
Here's the controversial take: consider disabling strict mode during development. I know, I know — strict mode catches bugs. But for rapid iteration, you might want to enable it only in CI:
// tsconfig.dev.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": false,
"noImplicitAny": false
}
}
Your development builds become lightning fast, and your CI still catches type errors before they hit production.
Smart Import Strategies and Dependency Management
Here's something that'll surprise you: how you import code matters more than what you import. Modern bundlers are smart, but TypeScript still has to type-check everything you reference.
Avoid barrel exports in large projects:
// Don't do this in utils/index.ts
export * from './string-utils';
export * from './date-utils';
export * from './math-utils';
export * from './validation-utils';
// When you import like this:
import { capitalize } from './utils';
// TypeScript loads and type-checks ALL utils, not just string-utils
Instead, import directly:
import { capitalize } from './utils/string-utils';
// Only string-utils gets loaded and checked
Use dynamic imports for code splitting and performance:
// Instead of importing heavy libraries at the top level
import * as d3 from 'd3';
// Load them when needed
async function createVisualization(data: any[]) {
const d3 = await import('d3');
return d3.select('body').append('svg')...;
}
This keeps your initial bundle smaller and reduces the TypeScript compiler's workload during development.
Be strategic about type-only imports:
// This forces runtime dependency
import { User } from './user-service';
// This is compile-time only
import type { User } from './user-service';
Type-only imports don't create runtime dependencies, making your code more modular and your builds faster.
Profiling and Monitoring: Know Where the Time Goes
You can't optimize what you can't measure. TypeScript includes built-in profiling tools that most developers never use.
Generate a build performance trace:
tsc --generateTrace trace
This creates a trace directory with detailed timing information. Open trace/trace.json in Chrome's DevTools Performance tab, and you'll see exactly where TypeScript spends its time.
Watch for these red flags in your trace:
- Functions with thousands of instantiations
- Types that take milliseconds (not microseconds) to resolve
- Files being re-checked multiple times
Use the --extendedDiagnostics flag during development:
tsc --extendedDiagnostics
You'll get output like:
Files: 1234
Lines: 45678
Nodes: 234567
Identifiers: 12345
Symbols: 23456
Types: 34567
Memory used: 123456K
I/O read: 2.34s
I/O write: 0.12s
Parse time: 1.23s
Bind time: 0.45s
Check time: 3.67s
If your check time is more than 2-3x your parse time, you've got type complexity issues to address.
Set up monitoring in CI to catch performance regressions:
# In your CI pipeline
tsc --extendedDiagnostics | grep "Check time" > build-times.log
Track this over time, and you'll spot when new code introduces performance problems.
The Real Performance Win
Here's what most performance guides won't tell you: the biggest TypeScript performance gains come from writing simpler code. Every generic constraint, every conditional type, every mapped type adds cognitive load for both you and the compiler.
The teams with the fastest TypeScript builds aren't using exotic compiler flags or complex build systems. They're writing straightforward, well-structured code that happens to be fast to type-check. Sometimes the best optimization is deleting code, not making it clever.
Your four-minute build can become a 30-second build. Your teammates will thank you, your CI pipeline will thank you, and your future self will definitely thank you when you're not waiting for the compiler to finish before seeing if your code actually works.