Why ESLint isn't enough (and what to do instead)
ESLint + @typescript-eslint is a great first line of defense, but many classes of TypeScript problems come from type-system assumptions, inference quirks, or runtime inputs — and those are not always caught by linter rules. This guide walks through common "type holes", pragmatic fixes you can apply immediately, and how to use the satisfies operator to preserve literal/shape information without losing type safety.
Checklist: quick things to enable
- Enable TypeScript strict mode in
tsconfig.json:"strict": true. - Turn on specific flags:
noImplicitAny,exactOptionalPropertyTypes,useUnknownInCatchVariables,noUncheckedIndexedAccess. - Add type-level tests with tsd or run a coverage tool such as type-coverage.
- Prefer unknown over any; avoid unchecked assertions like
as any.
Seven common TypeScript type holes and how to close them
1) Implicit any from untyped JSON / external inputs
Problem: JSON.parse or external APIs produce any so subsequent code silently assumes shapes.
const data = JSON.parse(body); // any
console.log(data.user.name); // runtime crash possible, no type errorFixes:
- Parse as
unknownand validate (Zod, io-ts, runtypes) or use a runtime guard. - Use
as constor satisfies when you have fixed fixtures/configs (examples below).
import { z } from 'zod';
const BodySchema = z.object({ user: z.object({ name: z.string() }) });
const parsed = JSON.parse(body) as unknown;
const result = BodySchema.parse(parsed); // throws or returns typed value
console.log(result.user.name); // safe, typed2) Overly permissive generics (excess properties slip through)
Problem: Generic wrappers can widen types and silently accept extra fields.
function wrapFixes:
- Add constraints on generics:
<T extends Expected>. - Use the satisfies operator to assert shape without collapsing literal types.
type Public = { id: string; name: string };
const payload = { id: '1', name: 'x', secret: 42 } satisfies Public; // compiler checks shape
// 'payload' keeps its literal types for other inference, but is validated against Public3) Widening of literal types in large config objects
Problem: You want the compiler to keep literal values but also validate shape. Plain objects often widen to base types.
const config = { mode: 'production', retries: 3 };
// config.mode is string not the literal 'production'Fix:
type Config = { mode: 'production' | 'development'; retries?: number };
const config = {
mode: 'production',
retries: 3
} satisfies Config; // keeps literal 'production' while checking structure4) Nullability and non-null assertions
Problem: Using ! or assuming DOM values exist leads to runtime errors.
const el = document.getElementById('app')!; // crash if missing
el.innerHTML = '...';Fixes:
- Narrow with runtime checks:
if (!el) throw new Error('missing');. - Use strict null checks in tsconfig and avoid blanket non-null assertions.
5) Unsafe use of any and as unknown as T
Problem: Casts bypass the type system and hide real problems.
const result = fetchSomething() as any as MyType; // bypasses checksFix:
- Introduce small runtime validators for edges where types cross the boundary.
- Replace casts with conversions that validate and map to the desired type.
6) Catch variables typed as any
Problem: Traditional TypeScript treated catch variables as any, letting unsafe assumptions propagate.
try { ... } catch (e) {
console.log(e.message); // no error at compile-time if 'e' is any
}Fix: enable useUnknownInCatchVariables and narrow inside the catch.
try { ... } catch (e: unknown) {
if (e instanceof Error) console.log(e.message);
else console.log('Non-Error thrown');
}7) Missing type tests in your CI
Problem: Changes can accidentally weaken types; relying on dev machines isn't enough.
Fix: Add true type tests and coverage tooling to CI. Examples below.
// package.json scripts
"scripts": {
"typecheck": "tsc --noEmit",
"type-tests": "tsd"
}
// Install
npm install -D typescript tsd type-coverage
// CI step
npm run typecheck && npm run type-tests && npx type-coverage --strictPractical ESLint + TS configuration recommendations
Use both linter rules and stricter compiler flags. Example minimal config additions:
// tsconfig.json (excerpt)
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true
}
}
// .eslintrc.js (excerpt)
module.exports = {
"extends": ["plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking"],
"rules": {
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/strict-boolean-expressions": "warn"
}
};Tradeoffs
- Stricter flags and runtime validators add upfront work and some runtime cost, but prevent costly runtime bugs and make refactors safer.
- The satisfies operator buys safety without collapsing literal types — helpful for configs and fixtures — but it's a compile-time-only check, so pair it with runtime validation for external input.
- Type tests and coverage increase CI time; balance the strictness you enforce automatically vs. developer ergonomics.
Conclusion — practical next steps
Start by enabling strict TypeScript flags, add a few linter rules that block unsafe assignments/calls, and introduce a small set of runtime validations where data crosses trust boundaries. Use the satisfies operator for configs/fixtures to keep literal typings while ensuring shape conformance. Finally, add lightweight type tests (tsd) and a type-coverage check to CI so type regressions are caught early.
Fast checklist: strict compiler, noImplicitAny, noUncheckedIndexedAccess, useUnknownInCatchVariables, satisfies for shapes, runtime validators for external inputs, and CI type tests.
Was this helpful?
Share this post
Comments (0)
Want to join the conversation?
Log in or sign up to leave a comment and share your thoughts.
Log in to Comment