In a small TypeScript project, one any or an occasional non-null assertion may remain local. In a codebase shared by several teams, the same shortcut can become part of a contract that dozens of modules depend on.
A loose type in a shared package spreads uncertainty to every consumer. An unchecked API response can fail deep inside business logic. A weakened compiler setting conceals an entire category of errors across the repository.
These are the TypeScript mistakes that become more expensive as a codebase grows — and a practical way to decide which ones deserve attention first.
Mistake
What it costs at scale
Disables useful checking wherever the value travels and tends to spread under delivery pressure.
2. Loosening strict compiler settings
Conceals a class of bugs instead of addressing it, weakening guarantees across the project.
3. Misusing non-null assertions
Replaces compile-time null handling with the possibility of a runtime failure.
4. Using type assertions as shortcuts
Forces the compiler to accept a shape that has not been verified.
5. Skipping runtime validation
Allows malformed API, database, webhook, or form data into otherwise typed code.
6. Letting module boundaries leak
Creates competing domain models, import cycles, and breaking changes across teams.
7. Over-engineering generics
Makes routine changes harder to review, explain, and maintain.
8. Allowing tsconfig and monorepo sprawl
Produces inconsistent guarantees and unnecessarily slow type-checking.
9. Migrating legacy JavaScript without a plan
Leaves behind files that compile as TypeScript but still provide little meaningful safety.
Why TypeScript mistakes become more expensive as a codebase grows
The cost of a typing decision depends on how many parts of the system rely on it.
If an incorrect type exists inside one module, correcting it may require a single edit. Once that type is exported from a shared package and consumed by eight services, the same correction becomes a coordinated migration. Each consumer may have built different assumptions around the original definition.
The type is no longer an implementation detail. It is a contract.
That distinction explains why TypeScript debt can remain almost invisible for months and then become difficult to unwind. The problem is not simply that an error survived for a long time. It is that more code began depending on it.
Overusing any — and misusing unknown as a substitute
any does more than weaken one annotation. It allows a value to pass through the type system without meaningful checks.
That is particularly dangerous at shared boundaries. A value typed as any can be assigned to a more specific type even when its runtime shape does not match. The rest of the code then operates under a guarantee that was never established.
Deadline pressure makes the problem cumulative. One team introduces any to unblock a release. Another consumes that type and adds an assertion of its own. Later, removing the original any produces errors across several packages, so the cleanup keeps slipping behind product work.
Replacing every any with unknown is not enough. unknown is safer because code must narrow the value before using it. But this offers no protection if the next line immediately forces the desired type:
const payload: unknown = await getPayload()
const user = payload as User
No validation occurred. The assertion merely gave the unsafe assumption a more respectable appearance.
The point of unknown is the check that follows it:
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value && 'email' in value
}
A practical target is not necessarily zero any. Some integrations and low-risk legacy areas may justify it temporarily. The important part is making its use visible.
The open-source type-coverage tool calculates the proportion of identifiers whose type is not any. Teams can record that percentage in CI and prevent pull requests from lowering it. This is more informative than counting literal any keywords because implicit and transitive uses also affect the result.
Loosening strict mode instead of fixing the types
Disabling a compiler option can make CI green without making the code correct.
Turning off strictNullChecks before a release, for instance, does not remove null-related defects. It tells the compiler to stop reporting them. Every package that inherits the configuration now operates with weaker guarantees, often without an explicit decision by the teams maintaining those packages.
Strictness does not have to arrive as a repository-wide switch. In a large or partially migrated system, divide the codebase into TypeScript projects or packages with their own configurations. New and actively maintained areas can adopt stronger settings while legacy modules move through the same process deliberately.
Two settings deserve particular attention because they are not included automatically by "strict": true:
exactOptionalPropertyTypes distinguishes an absent optional property from one explicitly assigned undefined, unless undefined is part of the declared type.
TypeScript 5.9 placed both options in the stricter type-checking section of the tsconfig generated by tsc --init.
Flag
What it catches
Included in strict?
Practical effect
Uses of null and undefined that have not been handled explicitly
Moves many "cannot read properties of undefined" failures into compile time
Values whose types cannot be inferred and would otherwise become implicit any
Prevents unannotated any from entering unnoticed
Assignments that confuse a missing property with one set to undefined
Preserves the runtime distinction between absence and explicit undefined
Array or index-signature lookups that may not return a value
Forces callers to handle missing keys and out-of-range access
The effect of noUncheckedIndexedAccess is easy to see:
const users = ['Alice', 'Bob']
const user = users[5]
// Default configuration:
// user: string
// With noUncheckedIndexedAccess:
// user: string | undefined
The first type describes what the array is intended to contain. The second describes what this particular lookup can actually return.
Non-null assertions and type assertions used as shortcuts
The non-null assertion operator tells TypeScript that a value cannot be null or undefined:
const email = user.profile!.email
A type assertion makes a similar promise about shape:
const user = response as User
Neither operation validates the value at runtime. Both override the compiler's uncertainty with the developer's confidence.
That can be justified when the code has information TypeScript cannot preserve or express. A controlled test fixture is one case. Another is a value narrowed before entering a closure when the compiler cannot carry that narrowing across the boundary.
The warning sign is convenience. If ! appears because handling undefined would require another branch, the branch probably matters. If as appears directly after reading external data, the code does not have a typing problem; it has a missing validation step.
A wrong non-null assertion usually fails near the point of use. A wrong type assertion can be harder to trace because the value may travel through several functions before code finally accesses the field that was never there.
Assertions should encode knowledge the program already established. They should not manufacture knowledge the program does not have.
Trusting external data without runtime validation
TypeScript types do not exist after compilation. An interface therefore cannot verify an API response, form submission, webhook payload, configuration file, or database result.
This code looks typed but proves nothing about the response:
const data: User = await response.json()
If the endpoint renames a field, returns a string where the application expects a number, or serves an older schema version, the declared type remains unchanged. The compiler accepts the code because it was instructed to trust the assignment.
The failure then occurs later — perhaps while calculating a value, rendering a component, or applying business rules — far from the boundary where the invalid data entered.
Runtime validation should sit at trust boundaries:
Incoming requests and webhooks
Environment variables and configuration files
Database reads where the stored shape cannot be assumed
Internal values that have already passed through a validated boundary do not need to be checked repeatedly.
Zod is a common choice because the same schema can validate the runtime value and produce its TypeScript type:
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
})
type User = z.infer<typeof UserSchema>
const payload = await response.json()
const user = UserSchema.parse(payload)
The schema becomes the source of both guarantees, reducing the chance that runtime validation and static types drift apart. Libraries such as io-ts cover similar ground with a more functional model and greater conceptual overhead.
The useful rule is narrow: validate data when it enters the system, then rely on TypeScript inside the boundary.
Leaky module boundaries and unclear type ownership
Large codebases often contain several definitions of the same domain concept.
User exists in the authentication service, another version appears in billing, and the frontend maintains a third. The definitions begin similarly but diverge as each team adds fields for its own needs. Alternatively, the repository creates one shared "god type" filled with optional properties until it can describe every variation and guarantee almost nothing.
These are ownership failures expressed through types.
A shared model with no clear owner can be changed by anyone, while no team is responsible for understanding every consumer. Breaking changes remain silent until downstream packages compile, tests fail, or production data reaches an incompatible path.
Each important domain type needs a defined home. Depending on the architecture, that may be the service that owns the domain or a dedicated contracts package. Other modules should import the contract or derive local views from it rather than redeclaring near-duplicates.
Changes to shared types should receive the same care as changes to public APIs:
identify the owning team;
review affected consumers;
distinguish additive changes from breaking ones;
provide a migration path when several packages must change;
version externally distributed contracts where appropriate.
External capacity does not solve weak ownership by itself. When evaluating TypeScript development companies, examine how they handle shared contracts across team and service boundaries. Adding developers without clarifying ownership may simply produce more variants of the same type.
Over-engineered generics and type gymnastics
TypeScript can express intricate relationships through conditional types, mapped types, recursive definitions, and several layers of generic constraints. The fact that a relationship can be encoded does not mean application code benefits from encoding it.
A short function surrounded by thirty lines of type machinery may prevent a narrow category of incorrect calls. It may also make routine changes dependent on the one engineer who understands the construction.
That cost appears in review and maintenance rather than at runtime. Reviewers spend longer proving that a small change preserves an elaborate type. New team members avoid modifying it. Error messages expand into several screens of inferred types that reveal little about the actual mistake.
Complex generic types make sense for stable, heavily reused library APIs where many consumers benefit from precise inference. They are harder to justify in ordinary application code with a small number of known call sites.
A useful test is comparative: if understanding the type takes longer than understanding the behavior it protects, simplify it. A named intermediate type, an explicit overload, or a small amount of duplication may be easier to maintain than one theoretically elegant abstraction.
Motivated and focused experts for up to 60% less than locals, delivered in days, not months.
tsconfig and monorepo sprawl
A large repository rarely has only one tsconfig.json. It has a base configuration, package-level configurations, test configurations, build configurations, and a few historical files that no one is confident enough to remove.
The problem is not the number of files alone. It is configuration drift.
One package enables strict checks while another silently overrides them. A test config includes the whole repository. A broad include pattern brings generated files into the program. A package compiles independently but does not participate correctly in the dependency graph.
Without clear project boundaries, TypeScript may treat much of the workspace as one large program and reconsider far more code than a small change warrants.
Project references, available since TypeScript 3.0, let a repository define smaller TypeScript projects and their dependencies. The build mode, tsc -b, can then determine which projects are out of date and build the graph in the appropriate order.
A typical package configuration includes composite and declares its references:
{
"compilerOptions": {
"composite": true,
"declaration": true
},
"references": [{ "path": "../shared" }]
}
There is also little reason to make the TypeScript compiler handle every stage of the build if another tool already performs fast transpilation. Many teams use esbuild, SWC, or a framework-specific build tool for emission and run:
tsc --noEmit
as a separate type-checking gate.
Project references only help when the graph is configured consistently. One package missing composite, an accidental circular dependency, or a root config that re-includes the entire source tree can erase much of the benefit. The configuration itself therefore needs ownership, review, and tests in CI — not just inheritance from a base file no one revisits.
Tracking TypeScript debt without attempting a rewrite
Finding type debt is usually easier than deciding what to do with it.
A report may reveal hundreds of any values, unsafe assertions, inconsistent compiler settings, duplicate models, and packages that cannot be checked independently. Presented as one backlog, the result is too broad to compete with roadmap work. Teams either postpone it or begin a repository-wide cleanup that stalls once feature delivery takes priority.
A simple triage matrix turns that inventory into an order. Evaluate each issue on two axes:
Production risk: Can it cause runtime failure, invalid data, or silent corruption?
Fix effort: Is it a local edit or a change spanning packages and teams?
Low fix effort
High fix effort
Fix first. Remove incorrect ! assertions and replace as casts that bypass a check. These are often localized changes with direct runtime value.
Sequence deliberately. Address widespread any, weakened strict settings, and missing boundary validation in controlled stages.
Batch opportunistically. Simplify over-engineered generics and clean up cosmetic type duplication while working in the affected files.
Schedule as engineering investment. Plan boundary redesign, monorepo configuration work, and long-running legacy migrations separately from incident-driven fixes.
These placements are starting points, not universal judgments. A duplicated payment type may carry far more production risk than a duplicated internal reporting model. A missing validation layer may be a small patch for one endpoint and a multi-quarter program across dozens of integrations.
The matrix is useful because it forces those distinctions before implementation begins. "We have TypeScript debt" becomes a set of explicit decisions: fix these items now, plan these changes separately, and defer the remainder knowingly.
Mistakes teams make when migrating legacy JavaScript
A file is not meaningfully migrated because its extension changed from .js to .ts.
The weakest migration pattern is easy to recognize: rename the file, add enough any annotations and assertions to make it compile, and move on. The result still lies about its values, but it now carries the appearance of TypeScript safety.
There is no need to begin with a mass rename. TypeScript can check JavaScript directly through allowJs and checkJs. Teams can also enable checking file by file with // @ts-check and add JSDoc annotations where inference is insufficient.
This interim stage exposes real problems while the files remain JavaScript. Once the types are understood, changing the extension becomes a smaller mechanical step rather than the beginning of the investigation.
Strictness should also be introduced as a sequence:
-
Enable checking for a defined package or directory.
-
Resolve the errors that reveal actual bugs or ambiguous contracts.
-
Add the next compiler option.
-
Prevent new code from falling below the new baseline.
-
Repeat for the next part of the system.
Migration competes with feature delivery, so capacity matters. Pulling senior product engineers into a multi-quarter cleanup can delay both the migration and the roadmap. Some organizations ring-fence a dedicated team, others hire TypeScript developers for your project to carry out the migration while existing teams retain product ownership.
Either model needs a measurable finish line. "Add types" is not a scope. "Enable strictNullChecks for these packages, remove uncovered assertions at external boundaries, and raise type coverage without lowering it in CI" is.
Where to start
A large rewrite is rarely the right first response to TypeScript debt. It replaces a collection of known problems with a broad migration that must compete against every product deadline.
Start by measuring the repository. Record type coverage, compiler configurations, unsafe assertions, unvalidated boundaries, shared contracts, and project-reference gaps. Then rank the findings by production risk and effort.
Fix the high-risk local problems first. A false non-null assertion in a frequently used path deserves attention before an elaborate but harmless generic type. An unvalidated payment response matters more than a low-risk any inside a build script.
The objective is not type purity. A codebase with a deliberate any in an isolated low-risk area and validated external boundaries may be safer than one that reports full strictness while blindly trusting incoming data.
Good TypeScript maintenance makes uncertainty visible, contains it, and assigns ownership to it. The remaining debt should be there because the team chose to carry it — not because no one knew where it entered.
Frequently asked questions
Is TypeScript worth adding to a large JavaScript codebase already in production?
Usually, because the value of shared contracts increases with the number of modules and developers working in the system. The migration does not need to happen all at once. allowJs, checkJs, JSDoc, and file-level // @ts-check let teams find type errors in existing JavaScript before renaming files.
The return depends on how the migration is managed. Converting files without strengthening their types adds maintenance work without delivering much safety.
How strict should tsconfig be during a legacy migration?
Strictness should be the destination, but enabling every option across the entire repository at once may create more work than the team can absorb.
Introduce settings by project or package. noImplicitAny and strictNullChecks usually provide the most immediate value because they expose missing annotations and unhandled nullable values. Once that baseline is stable, evaluate opt-in settings such as noUncheckedIndexedAccess and exactOptionalPropertyTypes.
New code should not continue adding debt while older modules are being migrated.
Does TypeScript slow down builds in large repositories?
It can when the repository is treated as one program or when tsc is responsible for both type-checking and emission.
Project references and tsc -b give TypeScript an explicit dependency graph, allowing it to work with smaller projects. A fast transpiler can handle output while tsc --noEmit performs type-checking separately.
Configuration errors can negate those improvements, so build performance should be measured rather than assumed.
Can TypeScript replace runtime validation?
No. TypeScript types are erased during compilation and cannot inspect values that arrive while the program is running.
API responses, form submissions, webhooks, database records, environment variables, and other external inputs still require runtime validation. Once a value has been checked at the boundary, static types can protect the code that uses it internally.
Motivated and focused experts for up to 60% less than locals, delivered in days, not months.