Y
our TypeScript project has three linting configs that contradict each other, a tsconfig someone copy-pasted from a 2021 blog post, and a PR review culture where half the comments are about formatting. Nobody's shipping features. Everyone's fixing tooling.I've seen this at almost every team that adopted TypeScript without intentionally setting it up. They got TypeScript. They didn't get a TypeScript project. There's a difference.
This post is the setup I'd put in place on day one. Strict mode. Bun as the runtime and package manager. Biome for linting and formatting. A project layout that doesn't collapse under ten engineers. And a tsconfig that actually enforces the things TypeScript is good at enforcing.
It's opinionated. That's the point.
Why Most TypeScript Setups Stay Broken
The typical TypeScript project accumulates tools over time: ESLint added when someone got annoyed at a bug, Prettier added when the first formatting argument broke out, Husky added when lint-on-commit became a request, lint-staged added because Husky alone isn't enough, a tsconfig someone found on Stack Overflow, and a second tsconfig someone added for ts-node because the first one didn't work for scripts.
Six months in, you have five configuration files that partially overlap and occasionally conflict. Nobody knows which one wins.
The real cost isn't the misconfiguration itself. It's the cognitive tax. Every engineer on the team spends some fraction of their attention not on the product, but on "why did CI fail when it passed locally?" That fraction compounds.
A clean setup is cheap to build at the start and expensive to retrofit. Most teams do it backwards.
The Toolchain
Three tools. That's the whole thing.
TypeScript in strict mode is the baseline. Everything else is in service of making that strict mode actually mean something.
Bun as the runtime and package manager. It replaces Node.js for most use cases and npm/yarn simultaneously. Install times drop from 30-60 seconds to under 5. Test running is faster. Script execution is faster. It handles TypeScript natively without a separate compilation step during development.
Biome for linting and formatting. It replaces ESLint and Prettier with a single binary. One config file. Runs in milliseconds. No plugin conflicts, no version mismatches between eslint-plugin-* packages. The formatting is opinionated and intentionally less configurable than Prettier which is exactly why it stops arguments.
The instinct is to add more tools. The discipline is to resist that instinct until you have a concrete reason.
The tsconfig That Actually Works
Most tsconfigs I inherit have "strict": true and then a graveyard of overrides that quietly undo it.
Untitled1{2 "compilerOptions": {3 "target": "ES2022",4 "module": "NodeNext",5 "moduleResolution": "NodeNext",6 "lib": ["ES2022"],7 "outDir": "./dist",8 "rootDir": "./src",9 "strict": true,10 "noUncheckedIndexedAccess": true,11 "noImplicitReturns": true,12 "noFallthroughCasesInSwitch": true,13 "exactOptionalPropertyTypes": true,14 "forceConsistentCasingInFileNames": true,15 "skipLibCheck": true,16 "declaration": true,17 "declarationMap": true,18 "sourceMap": true19 },20 "include": ["src/**/*"],21 "exclude": ["node_modules", "dist"]22}
A few options worth explaining.
"module": "NodeNext" with "moduleResolution": "NodeNext" is the right pairing for Node.js applications in 2025. It correctly handles ESM imports, including requiring the .js extension on local imports. Don't use "module": "CommonJS" unless you're building a library that needs to support CJS consumers.
"noUncheckedIndexedAccess": true is the flag most teams skip that makes an immediate difference. With it, array[0] is typed as T | undefined, not T. This forces you to handle the case where the index doesn't exist. It will break existing code. Fix the code.
"exactOptionalPropertyTypes": true means a property typed as string | undefined can't be explicitly set to undefined on an object where the property is marked optional. This catches a real category of bugs the distinction between a missing key and a key explicitly set to undefined matters in serialization, API contracts, and database writes.
skipLibCheck: true is fine. It skips type checking of .d.ts files in node_modules. Without it, you inherit type errors from your dependencies that you can't fix. With it, you only see errors in your own code.
Project Layout That Survives Growth
The structure that holds up as the team grows:
src/
api/ # HTTP layer: routes, handlers, middleware
domain/ # Business logic: pure functions, no framework deps
infra/ # External integrations: DB, queues, third-party APIs
lib/ # Shared utilities with no domain knowledge
types/ # Shared TypeScript interfaces and types
tests/
unit/
integration/
scripts/ # One-off or operational scripts
The rule that matters: domain/ has zero dependencies on api/ or infra/. It knows nothing about Express, nothing about Postgres. Business logic should be testable without standing up a database.
This sounds obvious. It rarely holds in practice. The fastest way to violate it is importing db directly inside a service function "just this once." That's how you end up with business logic that can't be unit tested and integration tests that take ten minutes to run.
Enforce it with Biome's import restrictions or through code review. The point is to decide the rule before someone breaks it, not after.
Biome Configuration That Requires No Discussion
Untitled1{2 "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",3 "vcs": {4 "enabled": true,5 "clientKind": "git",6 "useIgnoreFile": true7 },8 "formatter": {9 "enabled": true,10 "indentStyle": "space",11 "indentWidth": 2,12 "lineWidth": 10013 },14 "linter": {15 "enabled": true,16 "rules": {17 "recommended": true,18 "correctness": {19 "noUnusedImports": "error",20 "noUnusedVariables": "error"21 },22 "suspicious": {23 "noExplicitAny": "error"24 },25 "style": {26 "noVar": "error",27 "useConst": "error"28 }29 }30 },31 "javascript": {32 "formatter": {33 "quoteStyle": "double",34 "semicolons": "always"35 }36 }37}
noExplicitAny: "error" generates the most initial resistance. Teams that have been using any as an escape hatch will hit errors immediately. That's the point. any is a promise to the type system that you're opting out of its help. Do it enough and you're not writing TypeScript you're writing JavaScript with extra friction.
For VSCode: install the Biome extension, enable format-on-save, done. No separate Prettier extension fighting it.
The Part Most Teams Get Wrong
They turn on strict mode and then spend the next sprint disabling errors instead of fixing them.
I've seen // @ts-ignore used 200+ times in codebases that had "strict": true in the tsconfig. At that point, strict mode isn't protecting you. It's theater.
The real discipline is deciding once as a team that the errors strict mode surfaces are real problems worth fixing, not inconveniences worth suppressing. Tooling can enforce noExplicitAny. It can't enforce the judgment that the underlying code is wrong and needs to change. That's a team agreement.
When migrating an existing project to strict mode, do it incrementally. Use // @ts-expect-error (not // @ts-ignore) with a comment explaining what needs to change. The difference matters: @ts-ignore silences the error always, @ts-expect-error fails if the error disappears meaning TypeScript tells you when you can remove the comment. Track these as tasks, work through them over weeks, and don't suppress and forget.
Real-World Example: How Stripe Approaches TypeScript at Scale
Stripe's engineering team has written publicly about their TypeScript practices. One consistent theme: they treat type errors as bugs, not warnings. Their codebase runs strict mode across millions of lines, and the discipline isn't technical it's cultural. Every any requires a justification. Every @ts-ignore gets a comment explaining why and a ticket to remove it.
The result is a codebase where the type signatures are documentation you can trust. When a function says it takes a PaymentIntent, it means a PaymentIntent. Not "probably a PaymentIntent, or maybe whatever was passed in from the API response without validation."
You don't need Stripe's headcount to get the same benefit. You need the same decision: the type system is telling you something true, and suppressing it doesn't make the underlying issue go away.
A more common example: a Series B payments API we reviewed had any in 340 places. Three weeks of replacements found 18 actual bugs places where the data being passed wasn't what the function expected. Not hypothetical. Bugs already in production, silently causing wrong behavior in edge cases.
The type system found 18 bugs that code review and testing had missed. That's the return on strict mode.
FAQ
Q: Is Bun production-ready in 2025? A: For most Node.js use cases, yes. Bun 1.x has been stable for over a year, passes the Node.js compatibility tests, and handles HTTP servers, file I/O, and TypeScript natively. The edge cases where it's not ready: some native npm packages that depend on Node.js internals, and certain edge runtimes. For a standard API or backend service, it's production-ready.
Q: Can Biome fully replace ESLint? A: For most teams, yes. Biome covers the most important ESLint rules and all of Prettier's formatting. If your project has specific custom ESLint plugins an accessibility auditor, a custom domain rule you may still need ESLint for those specific plugins. But don't use ESLint for generic linting and formatting if Biome handles it. The performance difference isn't marginal; it's an order of magnitude.
Q: Do we need Husky and lint-staged with this setup?
A: Probably not. Biome runs fast enough that calling it on the full project in a pre-commit hook is viable. If you want commit-time checks, a two-line shell script calling biome check is enough. lint-staged adds complexity that Biome's speed makes unnecessary.
Q: Should we use path aliases in tsconfig?
A: With caution. Path aliases like @/components are convenient until you need to run the code outside the TypeScript compiler in a script, a test runner, or a bundler. Each tool needs its own alias configuration. If you use them, pick one pattern, configure it everywhere at setup time, and don't add more aliases incrementally. If you're unsure, skip them. Relative imports are boring and reliable.
Q: What about monorepos?
A: Bun has native monorepo support via workspaces. The setup is nearly identical one root biome.json, one base tsconfig that each package extends, Bun workspaces in package.json. The main discipline is keeping packages genuinely separate: no circular dependencies, clear ownership per package. Biome can help enforce import boundaries at the monorepo level.
The argument about which linter to use, which formatter is better, which runtime is "really" production-ready all of that is cheaper to settle with a decision than to relitigate in every PR review.
Pick a stack. Write it down. Enforce it with tooling.
If you're starting from an existing mess, the migration cost is higher but the process is the same: one tool at a time, starting with the thing causing the most pain. We help teams make these calls as part of architecture reviews if your toolchain is already slowing you down, the debt audit framework is a good starting point.
Internal Reference Logs: