From noImplicitAny to type-coverage: the practical migration path
Scattering any unchecked through your code disables type checking exactly where it matters most. This article shows the practical path to a type-safe codebase: enabling noImplicitAny deliberately, tightening ESLint rules step by step, using unknown and generics as replacements, and making progress measurable with type-coverage, all without blocking ongoing development.
Table of Contents
- 1. Why any silently defeats the type checker
- 2. Enabling noImplicitAny step by step
- 3. ESLint strategy: no-explicit-any from warn to error
- 4. unknown instead of any: type safety for external data
- 5. Generics instead of any[] for reusable functions
- 6. Record<string, unknown> instead of any for loose objects
- 7. Function overloads instead of any parameters
- 8. Measuring progress with type-coverage
- 9. any code compared side by side with the typed alternative
- 10. Summary
- 11. FAQ
1. Why any silently defeats the type checker
any is not really a type in TypeScript, it's an escape hatch that switches off the entire type checker for a variable, parameter, or return value. From that point on, the compiler checks nothing: no property access, no method calls, no compatibility with other types. The real problem doesn't show up where the any was written, though, it shows up at every place that later consumes that value. A single any buried in a deeply nested utility function is enough to undermine the safety of an entire call tree.
Through TypeScript's type inference, any spreads like a stain. If a function's return type isn't explicitly annotated and internally touches any, the compiler automatically infers any as the return type, and every caller inherits that gap without seeing a single warning. The effect is even more severe with generic functions: instantiating a generic parameter with any collapses the entire type check within that instance to any, even if the function itself is otherwise cleanly typed. In a grown codebase with hundreds of modules, a handful of such roots is enough to leave large parts of the application effectively untyped.
2. Enabling noImplicitAny step by step
The compiler flag noImplicitAny is the baseline requirement for a type-safe codebase: it forces TypeScript to report any parameter, variable, or return value without a discernible type as an error instead of silently treating it as any. In a freshly set up codebase, you simply flip the flag in tsconfig.json. In a grown legacy codebase with thousands of files, though, flipping it immediately produces hundreds of errors at once, a state no team can fix in one pass without blocking ongoing feature work.
The practical path is a gradual migration through a second tsconfig file that extends the base configuration and enables noImplicitAny only for an explicit list of already-checked directories. New files get added to that list once they're cleanly typed, and CI checks both configurations in parallel. The sensible migration order starts with leaf modules and utility functions that have no dependencies of their own, since they become error-free fastest and serve as a stable foundation for every consumer module. Only once that foundation layer is clean does it pay off to migrate the calling components and services that depend on it.
// tsconfig.json - base config, still permissive for the whole codebase
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": false,
"noImplicitAny": false,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
// tsconfig.strict.json - opt-in noImplicitAny for already migrated files only
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true
},
"include": [
"src/utils/**/*.ts",
"src/lib/format-currency.ts",
"src/lib/parse-api-response.ts"
]
}
// package.json - run both checks in CI, strict config only covers migrated files
// "scripts": { "typecheck": "tsc --noEmit", "typecheck:strict": "tsc -p tsconfig.strict.json --noEmit" }
3. ESLint strategy: no-explicit-any from warn to error
While noImplicitAny only catches implicit any, the ESLint rule @typescript-eslint/no-explicit-any additionally flags every deliberately written any: type assertions, explicit parameter types, and return types. The first step in a legacy codebase isn't to flip the rule to an error, it's to set it to warn and establish an honest baseline without blocking the CI pipeline. That number, often several hundred occurrences, becomes the starting point for every further plan.
ESLint's overrides mechanism lets you configure the rule differently per directory: directories that are fully migrated get error, everything else stays at warn for now. The decisive lever is a ratchet mechanism via --max-warnings: the budget is frozen at the current warning count and is only allowed to shrink with every merge, never grow. Any pull request that introduces new any occurrences fails the CI pipeline, while existing warnings get worked down gradually through dedicated cleanup commits. That prevents any renewed growth without requiring every existing warning to be fixed immediately.
// eslint.config.js - flat config with per-directory ratchet for no-explicit-any
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
// Baseline: warn everywhere so CI stays green while we measure the count
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
// Fully migrated directories: any is now a hard error
files: ['src/utils/**/*.ts', 'src/lib/**/*.ts', 'src/api/client.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
},
},
{
// Legacy directory not yet touched: still warn, tracked separately
files: ['src/legacy/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
// CI ratchet: budget only ever goes down, never up
// "lint:any-budget": "eslint . --max-warnings 42"
4. unknown instead of any: type safety for external data
The most common excuse for any is external, untrusted input: the result of JSON.parse, an API response, or user input from a form. In every one of these cases, unknown is the correct type, not any. unknown accepts any value, but refuses any access to properties or methods until the type has been explicitly narrowed. That forces the compiler to make you engage with the actual shape of the data instead of blindly trusting it.
Narrowing happens through a type guard function that uses an is predicate to tell the compiler which concrete type applies after a successful check. For API responses, the guard function typically uses typeof and in to verify that the expected fields exist and carry the right primitive types. If the check fails, the function throws an error, and the calling code stays fully type-safe afterward, without a single cast.
// Type guard narrows unknown to a known shape before use
interface ApiUser {
id: number;
email: string;
isActive: boolean;
}
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).id === 'number' &&
typeof (value as Record<string, unknown>).email === 'string' &&
typeof (value as Record<string, unknown>).isActive === 'boolean'
);
}
async function fetchUser(id: number): Promise<ApiUser> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isApiUser(data)) {
throw new Error('Unexpected API response shape for user');
}
// data is now narrowed to ApiUser, no cast needed
return data;
}
5. Generics instead of any[] for reusable functions
Reusable container and utility functions like groupBy, chunk, or a simple cache often end up written with any[] or any as the parameter and return type, because they're meant to work with arbitrary element types. The result is a function that's universally applicable but loses all type information at the call site: the return value is any, regardless of whether an array of strings or an array of order objects went in.
Generics solve exactly this problem without limiting reusability. A generic type parameter T binds the element type at the call site, so the return type gets inferred correctly automatically, a groupBy function with a generic key and element type returns exactly the expected Record<K, T[]>, with nothing for the caller to cast. The migration effort is manageable: most of the time it's enough to replace the any parameter type with a generic type parameter and leave the internal logic untouched, since generics don't play any role at runtime anyway.
// Generic groupBy replaces an any-based version and stays fully typed
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
keyFn: (item: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
interface Order {
id: number;
status: 'pending' | 'shipped' | 'cancelled';
}
const orders: Order[] = [
{ id: 1, status: 'pending' },
{ id: 2, status: 'shipped' },
{ id: 3, status: 'pending' },
];
// ordersByStatus is inferred as Record<'pending' | 'shipped' | 'cancelled', Order[]>
const ordersByStatus = groupBy(orders, (order) => order.status);
6. Record<string, unknown> instead of any for loose objects
For loosely structured objects, such as configuration objects, feature flags, or dynamically assembled options, many developers also reach for any because the exact shape seems unclear at development time. Record<string, unknown> is almost always the better choice here: the type expresses exactly what's meant, an object with arbitrary string keys whose values need to be checked individually before use.
The practical difference from any shows up at access time: config.someKey is an arbitrary value with no checking at all under any, whereas Record<string, unknown> forces the compiler to require you to type the value before use or narrow it with a type guard. For configuration objects with known required fields, a hybrid approach often pays off: an interface with the known fields combined with an index signature fallback for optional extensions, instead of reducing the whole object to unknown.
7. Function overloads instead of any parameters
Polymorphic functions that behave differently depending on the input type, for instance a formatter that accepts both numbers and date objects and returns a different result depending on the type, often end up written with any parameters, because a single signature can't cleanly express the variety of inputs. The result is a function that accepts anything but provides type safety neither to the caller nor to the implementation itself.
Function overloads solve this by declaring multiple explicit signatures ahead of the actual implementation, one signature per meaningful input-output combination. The compiler automatically picks the matching signature at the call site and reports an error if none fits. The implementation signature itself may be broader and work internally with a union, but it stays invisible to the caller. That way, every combination of input and return value gets exact types, without any showing up anywhere in the code.
8. Measuring progress with type-coverage
Without measurement, every any elimination effort stays a feeling instead of a fact. The npm package type-coverage counts, for every file, how many identifiers carry a concrete type versus how many fall back to any, and aggregates that into a percentage for the entire codebase. Unlike a plain error count, this figure also captures implicit any spots that neither ESLint nor the compiler reports under the current configuration.
In the CI pipeline, type-coverage --at-least 92 enforces a minimum value that fails the build as soon as type coverage drops below the threshold. The decisive advantage over a single snapshot is the trend: writing the percentage to a dashboard or artifact on every merge makes any creeping regression visible immediately, long before it becomes a real problem. The threshold itself gets raised regularly, just like the ESLint budget, as new areas of the codebase get migrated.
# package.json: enforce a minimum type-coverage percentage in CI
# "scripts": { "type-coverage": "type-coverage --at-least 92 --detail" }
$ npm run type-coverage
> type-coverage --at-least 92 --detail
src/legacy/order-export.ts:41:12: any
src/legacy/order-export.ts:58:3: any
src/utils/format-price.ts:9:22: any
3 uncovered identifiers found
9532 / 10285 (92.68%)
# CI gate fails the build only when the percentage drops below --at-least
9. any code compared side by side with the typed alternative
The table below matches the most common any patterns seen in practice against the recommended typed alternative for each. Every row stands for a recurring scenario in a grown codebase, from an API response to a catch block, and shows which change delivers more safety immediately without much effort.
| Scenario | any version | Typed alternative | Benefit |
|---|---|---|---|
| Parsing a JSON response | JSON.parse(str) as any |
as unknown + type guard |
Malformed shape surfaces right at parse time |
| Reusable utility | function first(arr: any[]): any |
function first<T>(arr: T[]): T | undefined |
Return type survives at the call site |
| Loose configuration | config: any |
config: Record<string, unknown> |
Access forces a check before use |
| Polymorphic function | function format(value: any): string |
Function overloads per input type | Compiler picks the matching signature automatically |
| catch block | catch (e: any) |
catch (e: unknown) + narrowing |
Error handling doesn't break on a wrong assumption |
The common thread across all five rows: none of the typed alternatives demand a radical rewrite. unknown instead of any at the declaration, a type guard or cast in exactly the right place, a generic parameter instead of a concrete any, each change is locally contained and fits into a single pull request. That fine-grained feasibility is exactly what makes gradual migration realistic in practice, while a complete rewrite fails in most teams for lack of a dedicated time budget.
Mironsoft
TypeScript migration, type safety, and tooling for grown codebases
Ready to make your TypeScript codebase safer?
We analyze your codebase, determine the actual any ratio with type-coverage, and set up a gradual migration with an ESLint ratchet and a noImplicitAny rollout, all without blocking your feature development.
Type coverage audit
Establish a baseline, prioritize critical any hotspots
ESLint & tsconfig setup
Ratchet configuration and a gradual noImplicitAny rollout
Team coaching
Embedding unknown, generics, and type guards into daily work
10. Summary
Gradually eliminating any from an existing codebase isn't a one-off project, it's a continuous process with clear stages: enable noImplicitAny first for leaf modules and utilities, then expand step by step to consumer modules. Set ESLint's @typescript-eslint/no-explicit-any to warn, establish a baseline, and tighten the rule to error per directory as soon as an area is clean. unknown instead of any for every external input, generics instead of any[] for reusable functions, Record<string, unknown> for loose objects, and function overloads for polymorphic APIs replace the most common any escape hatches with real type safety.
The decisive success factor is measurability instead of gut feeling. type-coverage delivers a hard number that can be enforced as a minimum threshold in the CI pipeline, one that's allowed to rise with every merge but should never drop. Combined with the ESLint ratchet via max-warnings, this creates a system that actively prevents new any occurrences while existing ones get worked down gradually, with no risky big-bang rewrite of the entire codebase.
Gradually Eliminating any - The Essentials at a Glance
noImplicitAny rollout
Enable step by step via a second tsconfig with an include allowlist, leaf modules first.
ESLint ratchet
no-explicit-any starts at warn, then error per directory. The max-warnings budget shrinks with every merge.
Typed alternatives
unknown + type guards, generics instead of any[], Record<string, unknown>, function overloads instead of any parameters.
Measuring progress
type-coverage --at-least as a CI gate, document the trend in a dashboard, raise the threshold regularly.