
Migrate Js To Modern Typescript
- 80 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
migrate-js-to-modern-typescript is a Claude Code skill in the AI & Agent Building category.
Key points
- migrate-js-to-modern-typescript
- AI & Agent Building
- AI-coding skill
Migrate Js To Modern Typescript by the numbers
- 80 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,257 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill migrate-js-to-modern-typescriptAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 80 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with migrate-js-to-modern-typescript.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when migrate-js-to-modern-typescript is a claude code skill in the ai & agent building category.
What you get
Structured output aligned to migrate-js-to-modern-typescript: migrate-js-to-modern-typescript; AI & Agent Building; AI-coding skill.
Files
JavaScript to TypeScript Migration Best Practices
Guide for taking a JavaScript codebase to strict, modern TypeScript without a big-bang rewrite. Contains 42 rules across 7 categories, prioritized by impact to drive an incremental, file-by-file migration that keeps the build compiling at every step.
When to Apply
Reference these guidelines when:
- Converting a
.jscodebase to.ts(whole project or one module at a time) - Adding types to existing JavaScript via JSDoc or annotations
- Choosing a
tsconfigandallowJsstrategy for a mixed JS/TS repo - Turning on
strictmode or individual strict flags on a large codebase - Replacing
any,ascasts, and!assertions left over from a quick conversion - Validating external data (JSON, env, API responses) so the types you wrote are true at runtime
- Converting CommonJS to ESM, prototypes to classes, and other JS idioms to TS
- Updating the build, runner, and CI to type-check and publish TypeScript
How the Migration Flows
tsconfig & strategy → strictness ratchet → type the surfaces → kill any/casts
→ validate runtime boundaries → convert JS idioms → tooling/build/CIDecisions at the front cascade: a wrong tsconfig or a top-down conversion order forces you to re-type modules twice, and an early any flood poisons everything downstream. Work from the front of this pipeline and from the leaves of the dependency graph inward.
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Migration Setup & tsconfig | CRITICAL | setup- |
| 2 | Strictness Ratcheting | CRITICAL | strict- |
| 3 | Typing Public Surfaces | HIGH | surface- |
| 4 | Replacing any & Unsafe Casts | HIGH | unsafe- |
| 5 | Runtime Data Validation | MEDIUM-HIGH | runtime- |
| 6 | JS-to-TS Idiom Conversion | MEDIUM | idiom- |
| 7 | Tooling & Build Migration | LOW-MEDIUM | tooling- |
Quick Reference
1. Migration Setup & tsconfig (CRITICAL)
- `setup-allowjs-checkjs-bridge` — Enable allowJs and checkJs for incremental migration
- `setup-migrate-leaves-first` — Convert dependency leaves before their dependents
- `setup-jsdoc-before-rename` — Type JS with JSDoc and @ts-check before renaming
- `setup-prefer-ts-expect-error` — Prefer @ts-expect-error over @ts-ignore for suppressions
- `setup-skiplibcheck-during-migration` — Set skipLibCheck to silence third-party type noise
- `setup-modern-module-resolution` — Set module and moduleResolution to a modern pair
- `setup-noemitonerror-isolatedmodules` — Enable isolatedModules and noEmitOnError for safe output
2. Strictness Ratcheting (CRITICAL)
- `strict-enable-flags-incrementally` — Enable strict flags one at a time, not all at once
- `strict-prioritize-null-checks` — Prioritize strictNullChecks for the highest bug yield
- `strict-no-implicit-any` — Enable noImplicitAny to surface every untyped value
- `strict-no-unchecked-indexed-access` — Enable noUncheckedIndexedAccess for index safety
- `strict-use-unknown-in-catch` — Type caught errors as unknown, not any
- `strict-exact-optional-property-types` — Separate missing from undefined with exactOptionalPropertyTypes
3. Typing Public Surfaces (HIGH)
- `surface-annotate-exported-signatures` — Annotate exported function signatures explicitly
- `surface-replace-jsdoc-with-types` — Replace JSDoc type tags with real annotations
- `surface-type-default-params` — Type default and optional parameters precisely
- `surface-interface-for-object-args` — Convert loose object arguments to named interfaces
- `surface-type-callbacks` — Type callback and higher-order parameters
- `surface-type-class-fields` — Declare class field types instead of relying on assignment
4. Replacing any & Unsafe Casts (HIGH)
- `unsafe-prefer-unknown-over-any` — Replace any with unknown at untrusted boundaries
- `unsafe-eliminate-as-casts` — Replace as casts with narrowing or validation
- `unsafe-avoid-double-assertion` — Avoid double assertions that force unrelated types
- `unsafe-type-dynamic-property-access` — Type dynamic property access with Records or index signatures
- `unsafe-replace-function-type` — Replace the Function type with specific call signatures
- `unsafe-narrow-instead-of-nonnull` — Narrow values instead of using the non-null assertion
5. Runtime Data Validation (MEDIUM-HIGH)
- `runtime-validate-external-data` — Validate external data at the boundary
- `runtime-type-environment-variables` — Parse and type environment variables once
- `runtime-derive-types-from-schemas` — Derive static types from runtime schemas
- `runtime-type-guards-at-boundaries` — Write type guards for untyped library returns
- `runtime-type-json-parse` — Type JSON.parse results through validation
6. JS-to-TS Idiom Conversion (MEDIUM)
- `idiom-require-to-import` — Convert require and module.exports to ESM syntax
- `idiom-prototype-to-class` — Convert prototype constructors to class syntax
- `idiom-type-only-imports` — Use import type for type-only imports
- `idiom-replace-arguments-object` — Replace the arguments object with rest parameters
- `idiom-enum-to-union-or-const` — Convert frozen-object enums to const objects or unions
- `idiom-default-export-to-named` — Prefer named exports over default exports
- `idiom-optional-chaining-over-guards` — Replace manual existence guards with optional chaining
7. Tooling & Build Migration (LOW-MEDIUM)
- `tooling-declare-untyped-modules` — Provide ambient declarations for untyped dependencies
- `tooling-install-types-packages` — Install @types packages before casting library returns
- `tooling-use-tsx-over-ts-node` — Run TypeScript directly with tsx instead of ts-node flags
- `tooling-emit-declaration-files` — Emit declaration files for migrated libraries
- `tooling-typecheck-in-ci` — Add a type-check step to CI separate from the build
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Related Skills
typescript-refactor— Refactoring and modernizing code that is already TypeScripttypescript-advanced-patterns— Advanced type-level patterns once the migration is done
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
TypeScript
Version 0.1.0 TypeScript Migration Specialist May 2026
Note: This document guides agents and LLMs migrating JavaScript code to modern, strict TypeScript.
It is the compiled navigation index for the rule set. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Guide for migrating JavaScript codebases to strict, modern TypeScript without a big-bang rewrite, designed for AI agents and LLMs. Contains 42 rules across 7 categories, prioritized by impact from critical (migration setup and strictness ratcheting) to incremental (tooling and build). Each rule includes the reasoning, a production-realistic JavaScript anti-pattern, and the modern TypeScript fix, so an agent can drive an incremental, file-by-file migration that keeps the build green. Covers tsconfig and allowJs strategy, ordered strict-flag enablement, typing public surfaces, replacing any and unsafe casts, runtime boundary validation, JS-to-TS idiom conversion, and build/CI changes.
---
Table of Contents
1. Migration Setup & tsconfig — CRITICAL
- 1.1 Convert Dependency Leaves Before Their Dependents — CRITICAL (prevents re-typing modules twice)
- 1.2 Enable allowJs and checkJs for Incremental Migration — CRITICAL (enables file-by-file migration without big-bang rewrites)
- 1.3 Enable isolatedModules and noEmitOnError for Safe Output — MEDIUM-HIGH (prevents emitting broken JavaScript)
- 1.4 Prefer ts-expect-error over ts-ignore for Suppressions — HIGH (eliminates silently stale suppressions)
- 1.5 Set module and moduleResolution to a Modern Pair — HIGH (prevents import resolution mismatches)
- 1.6 Set skipLibCheck to Silence Third-Party Type Noise — HIGH (reduces error noise from untyped dependencies)
- 1.7 Type JS with JSDoc and ts-check Before Renaming — HIGH (prevents type errors at rename time)
2. Strictness Ratcheting — CRITICAL
- 2.1 Enable exactOptionalPropertyTypes to Separate Missing from Undefined — MEDIUM-HIGH (prevents absent-versus-undefined confusion)
- 2.2 Enable noImplicitAny to Surface Every Untyped Value — CRITICAL (eliminates invisible any-debt)
- 2.3 Enable noUncheckedIndexedAccess for Index Safety — HIGH (prevents undefined-index crashes)
- 2.4 Enable strict Flags One at a Time, Not All at Once — CRITICAL (reduces error floods to fixable batches)
- 2.5 Prioritize strictNullChecks for the Highest Bug Yield — CRITICAL (prevents the most common JS runtime crash)
- 2.6 Type Caught Errors as unknown, Not any — HIGH (prevents unsafe error property access)
3. Typing Public Surfaces — HIGH
- 3.1 Annotate Exported Function Signatures Explicitly — HIGH (prevents silent contract drift)
- 3.2 Convert Loose Object Arguments to Named Interfaces — HIGH (enables reuse and clearer error messages)
- 3.3 Declare Class Field Types Instead of Relying on Assignment — MEDIUM-HIGH (enables strict property initialization checks)
- 3.4 Replace JSDoc Type Tags with Real Annotations — MEDIUM-HIGH (eliminates type drift between JSDoc and code)
- 3.5 Type Callback and Higher-Order Parameters — HIGH (prevents any-propagation through callbacks)
- 3.6 Type Default and Optional Parameters Precisely — HIGH (enables caller autocomplete on options)
4. Replacing any & Unsafe Casts — HIGH
- 4.1 Avoid Double Assertions That Force Unrelated Types — MEDIUM-HIGH (prevents hidden type mismatches)
- 4.2 Narrow Values Instead of Using the Non-Null Assertion — MEDIUM (prevents reintroduced null crashes)
- 4.3 Replace any with unknown at Untrusted Boundaries — HIGH (prevents any from spreading through the codebase)
- 4.4 Replace as Casts with Narrowing or Validation — HIGH (eliminates unverified type assertions)
- 4.5 Replace the Function Type with Specific Call Signatures — MEDIUM (enables call-site argument checking)
- 4.6 Type Dynamic Property Access with Records or Index Signatures — MEDIUM-HIGH (enables typed map-style access)
5. Runtime Data Validation — MEDIUM-HIGH
- 5.1 Derive Static Types from Runtime Schemas — MEDIUM (maintains runtime and compile-time type sync)
- 5.2 Parse and Type Environment Variables Once — MEDIUM (eliminates scattered env-var reads)
- 5.3 Type JSON.parse Results Through Validation — MEDIUM (prevents untyped JSON propagation)
- 5.4 Validate External Data at the Boundary — MEDIUM-HIGH (prevents malformed-data crashes)
- 5.5 Write Type Guards for Untyped Library Returns — MEDIUM (prevents any from untyped libraries spreading)
6. JS-to-TS Idiom Conversion — MEDIUM
- 6.1 Convert Frozen-Object Enums to const Objects or Unions — MEDIUM (preserves literal values for narrowing)
- 6.2 Convert Prototype Constructors to class Syntax — MEDIUM (enables static analysis of object shapes)
- 6.3 Convert require and module.exports to ESM Syntax — MEDIUM (enables typed, tree-shakeable imports)
- 6.4 Prefer Named Exports over Default Exports — LOW-MEDIUM (enables reliable rename and autocomplete)
- 6.5 Replace Manual Existence Guards with Optional Chaining — LOW-MEDIUM (reduces nullable-chain boilerplate)
- 6.6 Replace the arguments Object with Rest Parameters — MEDIUM (enables typed variadic arguments)
- 6.7 Use import type for Type-Only Imports — MEDIUM (prevents accidental runtime imports)
7. Tooling & Build Migration — LOW-MEDIUM
- 7.1 Add a Type-Check Step to CI Separate from the Build — LOW-MEDIUM (prevents shipping unchecked types)
- 7.2 Emit Declaration Files for Migrated Libraries — LOW-MEDIUM (preserves types for downstream consumers)
- 7.3 Install types Packages Before Casting Library Returns — LOW-MEDIUM (enables free library type coverage)
- 7.4 Provide Ambient Declarations for Untyped Dependencies — LOW-MEDIUM (enables builds on untyped dependencies)
- 7.5 Run TypeScript Directly with tsx Instead of ts-node Flags — LOW-MEDIUM (eliminates fragile loader configuration)
---
References
1. https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html 2. https://www.typescriptlang.org/tsconfig/ 3. https://github.com/microsoft/TypeScript/wiki/Performance 4. https://google.github.io/styleguide/tsguide.html 5. https://effectivetypescript.com/ 6. https://www.totaltypescript.com/ 7. https://zod.dev/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Imperative Rule Title — identical to the title above}
{1-3 sentences explaining WHY this matters for a JS-to-TS migration: what untyped or unsafe pattern it removes, and what bug or churn it prevents downstream. Explain the reasoning so the model generalizes, not just the rule.}
Incorrect ({specific problem, not "bad"}):
{Production-realistic JavaScript or loosely-typed code being migrated.}
{// Comment naming the concrete cost — the unchecked access, the leaked any.}Correct ({specific fix, not "good"}):
{Modern, strict TypeScript — minimal diff from the incorrect version.}
{// Comment naming the concrete benefit.}{Optional sections as needed:}
Alternative ({context}):
{A second valid approach when one exists.}When NOT to use this pattern:
- {Exception where the simpler/older form is genuinely better.}
Reference: [{Authoritative Source Title}]({Source URL})
{
"version": "0.1.0",
"organization": "TypeScript Migration Specialist",
"technology": "TypeScript",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Guide for migrating JavaScript codebases to strict, modern TypeScript without a big-bang rewrite, designed for AI agents and LLMs. Contains 42 rules across 7 categories, prioritized by impact from critical (migration setup and strictness ratcheting) to incremental (tooling and build). Each rule includes the reasoning, a production-realistic JavaScript anti-pattern, and the modern TypeScript fix, so an agent can drive an incremental, file-by-file migration that keeps the build green. Covers tsconfig and allowJs strategy, ordered strict-flag enablement, typing public surfaces, replacing any and unsafe casts, runtime boundary validation, JS-to-TS idiom conversion, and build/CI changes.",
"references": [
"https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html",
"https://www.typescriptlang.org/tsconfig/",
"https://github.com/microsoft/TypeScript/wiki/Performance",
"https://google.github.io/styleguide/tsguide.html",
"https://effectivetypescript.com/",
"https://www.totaltypescript.com/",
"https://zod.dev/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Migration Setup & tsconfig (setup)
Impact: CRITICAL Description: The entire migration strategy is chosen here — how JS and TS coexist, what order files convert in, and how the compiler resolves modules. A wrong tsconfig or a top-down conversion order forces you to re-type the same modules twice and cascades errors through every downstream file.
2. Strictness Ratcheting (strict)
Impact: CRITICAL Description: Strictness is the defining axis of "modern" TypeScript and the source of nearly all migration value. The order you enable strict flags determines whether you face an unbounded error flood or fixable batches; strictNullChecks alone catches the most common JS runtime crash.
3. Typing Public Surfaces (surface)
Impact: HIGH Description: Exported function signatures, class fields, and option objects are the contract every importer relies on. An untyped seam propagates any across the entire call graph, so typing the surfaces first restores inference everywhere downstream.
4. Replacing any & Unsafe Casts (unsafe)
Impact: HIGH Description: Auto-migration scatters implicit and explicit any, as casts, and ! assertions used to silence errors. Each one disables checking and spreads silently; replacing them with unknown plus narrowing contains the damage at its source.
5. Runtime Data Validation (runtime)
Impact: MEDIUM-HIGH Description: External data — JSON, env vars, API responses, untyped library returns — is any at runtime no matter what the annotation claims. Validating at the boundary is the only way the type you wrote is actually true when the program runs.
6. JS-to-TS Idiom Conversion (idiom)
Impact: MEDIUM Description: CommonJS require, prototype constructors, the arguments object, and frozen-object enums are JS idioms TypeScript cannot type coherently. Converting them to ESM, classes, rest parameters, and as const unlocks inference and static analysis.
7. Tooling & Build Migration (tooling)
Impact: LOW-MEDIUM Description: The build chain must learn to run, type-check, and publish TypeScript. Ambient declarations, @types packages, a direct runner, declaration emit, and a dedicated type-check CI step keep the types you added enforced and available to consumers.
Prefer Named Exports over Default Exports
A default export has no name at the import boundary — importers may call it anything — so editor rename refactors and auto-import frequently miss it, and it interoperates awkwardly with CommonJS under esModuleInterop. Named exports keep a stable identity that tooling tracks across the whole codebase, which matters most while a migration is reshaping many modules at once.
Incorrect (default export — identity lost across the boundary):
// Each importer picks its own local name, so rename and autocomplete
// cannot follow this symbol reliably.
export default function createPaymentGateway(config: GatewayConfig): PaymentGateway {
return new StripeGateway(config)
}Correct (named export — stable, trackable identity):
export function createPaymentGateway(config: GatewayConfig): PaymentGateway {
return new StripeGateway(config)
}Reference: Google TypeScript Style Guide: Exports
Convert Frozen-Object Enums to const Objects or Unions
JavaScript "enums" built with Object.freeze({ ... }) lose their literal types when migrated naively — each value widens to string or number, so it can no longer drive narrowing or exhaustiveness checks. An as const object preserves the exact literal values, and a derived union type gives you a precise set to switch on.
Incorrect (frozen object — values widen to string):
// Status.Paid has type string, so it cannot drive an exhaustive switch.
const Status = Object.freeze({
Pending: "pending",
Paid: "paid",
Refunded: "refunded",
})Correct (as const preserves literals; derive a union):
const Status = {
Pending: "pending",
Paid: "paid",
Refunded: "refunded",
} as const
// A value and a type may share a name; this is the idiomatic enum replacement.
type Status = (typeof Status)[keyof typeof Status]
// "pending" | "paid" | "refunded" — usable in exhaustive narrowingThis also avoids the runtime and bundling quirks of TypeScript's enum keyword, which emits a lookup object and is not erased.
Reference: TypeScript Handbook: const assertions
Replace Manual Existence Guards with Optional Chaining
JavaScript defensive chains like a && a.b && a.b.c are verbose and widen the result type to include every falsy intermediate operand ("" | 0 | undefined), so the value you get back is messier than the one you wanted. Optional chaining (?.) short-circuits to undefined cleanly, and nullish coalescing (??) supplies a default only for null/undefined, expressing intent the compiler narrows precisely.
Incorrect (boolean-and chain widens the result type):
// city's type includes "" and any falsy intermediate, not just string.
const city = user && user.address && user.address.cityCorrect (optional chaining narrows precisely):
const city = user?.address?.city ?? "Unknown" // string, no stray falsy valuesReference: TypeScript 3.7: Optional Chaining
Convert Prototype Constructors to class Syntax
Prototype assignments scattered across a file give TypeScript no single declaration to type fields, methods, and inheritance from, so instances end up loosely typed and this is unchecked. A class consolidates the shape into one declaration the compiler can analyze fully, including visibility, readonly, and constructor parameter properties.
Incorrect (prototype-based — no coherent instance type):
function Cart(currency) {
this.currency = currency
this.items = []
}
Cart.prototype.add = function (item) {
this.items.push(item) // this is untyped; items could be anything
}Correct (class — one analyzable declaration):
class Cart {
private readonly items: LineItem[] = []
constructor(private readonly currency: Currency) {}
add(item: LineItem): void {
this.items.push(item)
}
}Reference: TypeScript Handbook: Classes
Replace the arguments Object with Rest Parameters
The arguments object is untyped, only array-like (so it lacks map, reduce, and friends), and unavailable inside arrow functions. A typed rest parameter (...values: number[]) gives each argument a checked type and a real array, so variadic functions are both safe and ergonomic after migration.
Incorrect (arguments object — untyped and not a real array):
function sum() {
// arguments is array-like and untyped; .reduce is not available on it.
let total = 0
for (let i = 0; i < arguments.length; i++) {
total += arguments[i]
}
return total
}Correct (typed rest parameter):
function sum(...values: number[]): number {
return values.reduce((total, value) => total + value, 0)
}Reference: TypeScript Handbook: Rest Parameters
Convert require and module.exports to ESM Syntax
CommonJS require returns a loosely typed value (often any), cannot carry import type, and blocks tree-shaking because the whole module object is pulled in at runtime. ESM import/export carries static types across the boundary, works with verbatimModuleSyntax, and matches how modern bundlers and Node's own ESM loader resolve modules.
Incorrect (CommonJS — require erases types):
const { formatPrice } = require("./money") // formatPrice is any
module.exports.renderReceipt = (cents) => formatPrice(cents)Correct (ESM — types cross the import boundary):
import { formatPrice } from "./money.js"
export function renderReceipt(cents: number): string {
return formatPrice(cents)
}Reference: TypeScript Handbook: Modules
Use import type for Type-Only Imports
Importing a symbol with a plain import when you only reference it as a type forces the emitter to keep a runtime import of that module — pulling in its side effects and breaking single-file transpilers under isolatedModules. import type is erased at compile time, so the intent is explicit and no runtime dependency is created.
Incorrect (value import used only for a type):
// Keeps a runtime import of ./gateway just to name its type, dragging in
// any module-level side effects and confusing isolatedModules transpilers.
import { PaymentGateway } from "./gateway.js"
function wire(gateway: PaymentGateway): void {
register(gateway)
}Correct (import type is erased):
import type { PaymentGateway } from "./gateway.js"
function wire(gateway: PaymentGateway): void {
register(gateway)
}With verbatimModuleSyntax enabled, the compiler enforces this distinction for you and errors on type-only symbols imported as values.
Reference: TypeScript 3.8: Type-Only Imports
Derive Static Types from Runtime Schemas
Declaring an interface and a separate validator for the same data creates two sources of truth that drift apart — the schema accepts a field the interface forgot, or vice versa, and the mismatch only shows up in production. Define the schema once and infer the static type from it (z.infer), so the runtime check and the compile-time type can never disagree.
Incorrect (interface and validator declared separately):
interface Order {
id: string
total: number
}
// The schema and the interface drift: add `currency` to one and the other
// silently disagrees, with no compile error.
const OrderSchema = z.object({ id: z.string(), total: z.number() })Correct (one schema, type inferred from it):
import { z } from "zod"
const OrderSchema = z.object({
id: z.string(),
total: z.number(),
currency: z.enum(["usd", "eur"]),
})
type Order = z.infer<typeof OrderSchema> // always matches the validatorReference: Zod: Type Inference
Parse and Type Environment Variables Once
process.env.X is typed string | undefined, and migrated JavaScript reads it directly in dozens of places, each assuming it exists and is the right type. Parse and validate the environment into a typed config object once at startup so the rest of the app consumes guaranteed types — and a missing variable fails loudly on boot, not deep in a request.
Incorrect (raw env reads scattered everywhere):
// string | undefined, read in many files, parsed ad hoc each time.
const pool = createPool({
max: Number(process.env.DB_POOL_MAX), // NaN when unset, no error
ssl: process.env.DB_SSL === "true",
})Correct (validate once into a typed config):
import { z } from "zod"
const Env = z.object({
DB_POOL_MAX: z.coerce.number().int().positive().default(10),
DB_SSL: z.enum(["true", "false"]).transform((v) => v === "true"),
})
export const env = Env.parse(process.env) // fails at startup if misconfigured
const pool = createPool({ max: env.DB_POOL_MAX, ssl: env.DB_SSL })Reference: Zod: Coercion
Write Type Guards for Untyped Library Returns
An untyped third-party function returns any, which silently poisons every value derived from it — the any flows outward with no error until something crashes. A user-defined type guard (value is T) verifies the shape at the single call site and narrows it, containing the any there instead of letting it leak through the program.
Incorrect (untyped return spreads any downstream):
// legacyParser.parse returns any; token and its fields are unchecked
// everywhere they travel after this line.
const token = legacyParser.parse(header)
return token.claims.sub // unchecked all the way downCorrect (a type guard contains the any at the boundary):
interface AuthToken {
claims: { sub: string; exp: number }
}
function isAuthToken(value: unknown): value is AuthToken {
return (
typeof value === "object" &&
value !== null &&
"claims" in value &&
typeof (value as AuthToken).claims?.sub === "string"
)
}
const parsed: unknown = legacyParser.parse(header)
if (!isAuthToken(parsed)) throw new Error("Invalid token")
return parsed.claims.sub // narrowed to AuthToken, checkedReference: TypeScript Handbook: Type Predicates
Type JSON.parse Results Through Validation
JSON.parse returns any, so its result spreads untyped through everything it feeds — and annotating the call site (JSON.parse(s) as Config) is an unchecked assertion that is simply false when the file on disk does not match. Assign the result to unknown and validate it, so the parsed value earns its type instead of claiming it.
Incorrect (annotated parse — the type is a lie):
// If the config file drifts from Config, this still compiles and the wrong
// shape flows everywhere with no runtime check.
const config = JSON.parse(readFileSync("config.json", "utf8")) as Config
startServer(config.port)Correct (parse to unknown, then validate):
import { z } from "zod"
const ConfigSchema = z.object({ port: z.number().int(), host: z.string() })
const raw: unknown = JSON.parse(readFileSync("config.json", "utf8"))
const config = ConfigSchema.parse(raw) // throws if the file drifts
startServer(config.port)Reference: Effective TypeScript: Item 71
Validate External Data at the Boundary
API responses, JSON files, and message payloads are any or unknown at runtime regardless of the annotation you write — casting them to a type is a claim the compiler cannot verify and the network does not honor. A schema validator (Zod, valibot) checks the shape once at the boundary and returns a value whose static type is guaranteed to match what actually arrived.
Incorrect (cast trusts the network blindly):
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return (await res.json()) as User // any malformed response crashes downstream
}Correct (validate, then the type is guaranteed):
import { z } from "zod"
const UserSchema = z.object({
id: z.string(),
email: z.email(), // Zod v4 top-level format helper
createdAt: z.coerce.date(),
})
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return UserSchema.parse(await res.json()) // throws with a precise path on mismatch
}Reference: Zod: Basics
Enable allowJs and checkJs for Incremental Migration
A TypeScript-only include forces you to rename every file before the project compiles again — the first git mv breaks every importer of a still-.js module. allowJs lets .js and .ts compile side by side so you migrate one file at a time, and checkJs type-checks the remaining JavaScript through JSDoc, finding bugs before you ever rename.
Incorrect (TS-only — project will not build until all files are renamed):
{
"compilerOptions": {
"strict": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}Only .ts files compile, so renaming the first of 400 files breaks every import of the modules still written in JavaScript.
Correct (allowJs + checkJs — JS and TS coexist during the migration):
{
"compilerOptions": {
"strict": true,
"allowJs": true,
"checkJs": true,
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/*.js"]
}allowJs keeps the build green while .js and .ts coexist, so you rename one file at a time. checkJs type-checks the remaining JavaScript via JSDoc, surfacing bugs before the rename rather than after.
Reference: Migrating from JavaScript
Type JS with JSDoc and ts-check Before Renaming
You can add types and fix type errors inside a .js file using a // @ts-check comment and JSDoc annotations, with no change to the build. When you later flip the extension to .ts, the file already type-checks — separating the risky semantic work from the mechanical rename so a rename never lands a wall of new errors.
Incorrect (rename first — every type error surfaces at once):
// payments.js renamed straight to payments.ts with no prior typing.
function chargeCard(amount, currency) {
return gateway.charge({ amount, currency })
}
// On rename: amount and currency are implicit any, gateway is untyped,
// and the return type is unknown — all surfacing in one overwhelming pass.Correct (annotate in place with JSDoc, then rename when green):
// @ts-check
/**
* @param {number} amount
* @param {"usd" | "eur"} currency
* @returns {Promise<ChargeResult>}
*/
function chargeCard(amount, currency) {
return gateway.charge({ amount, currency })
}
// Errors are found and fixed here, in JavaScript. The later rename to
// payments.ts is then a no-op for the type checker.Reference: Type Checking JavaScript Files
Convert Dependency Leaves Before Their Dependents
A module's types are only as good as the types of what it imports. Migrate leaf modules (those with no internal imports) first so their dependents inherit real types the moment they convert. Going top-down means the entry point is typed against any imports, and every fix has to be redone once the leaves are finally typed.
Incorrect (top-down — dependent typed against untyped imports):
// Entry point migrated first, while ./money is still untyped JavaScript.
import { formatPrice } from "./money.js" // formatPrice resolves to `any`
export function renderReceipt(totalCents: number): string {
// formatPrice returns `any`, so .padStart is unchecked — bugs hide here
return formatPrice(totalCents).padStart(12)
}Correct (leaf first — dependent inherits a real signature):
// money.ts migrated first, exporting a precise signature.
export function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`
}
// renderReceipt.ts now imports a typed formatPrice; .padStart is checked.
import { formatPrice } from "./money.js"
export function renderReceipt(totalCents: number): string {
return formatPrice(totalCents).padStart(12)
}Build the import graph (madge, dependency-cruiser, or tsc --listFiles) and migrate from the leaves inward.
Reference: Migrating from JavaScript
Set module and moduleResolution to a Modern Pair
Legacy "moduleResolution": "node" (now aliased node10) ignores package.json exports maps, so it resolves imports differently from your bundler and from Node's ESM loader — code that passes tsc then fails to resolve at runtime. Modern "bundler" or "nodenext" resolution matches how the code is actually loaded.
Incorrect (legacy resolution — tsc and runtime disagree):
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node"
}
}node ignores exports fields, so it may resolve a package's CommonJS entry while your bundler picks the ESM build — different types, runtime surprises that tsc never warned about.
Correct (bundler resolution for a bundled app):
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}bundler honours exports maps exactly as esbuild, Vite, and webpack do. For a Node service with no bundler, use "module": "nodenext" instead, which also requires explicit file extensions on relative imports.
Reference: TypeScript 5.0 Release Notes
Enable isolatedModules and noEmitOnError for Safe Output
Single-file transpilers (esbuild, swc, Babel) compile each file in isolation, so they cannot resolve const enums or tell whether a re-export is a type or a value — and silently emit wrong output. isolatedModules flags these patterns at design time, and noEmitOnError stops tsc from shipping JavaScript built from code that does not type-check.
Incorrect (transpiler-unsafe re-export, emitted anyway):
// A single-file transpiler cannot tell `Money` is a type, so it emits a
// runtime re-export that fails at load time.
export { Money } from "./money.js"Correct (isolatedModules-safe, no emit on error):
// `export type` marks this as carrying no runtime value, so every
// transpiler erases it correctly.
export type { Money } from "./money.js"
// In tsconfig: "isolatedModules": true and "noEmitOnError": trueReference: tsconfig: isolatedModules
Prefer ts-expect-error over ts-ignore for Suppressions
A migration accumulates many temporary suppressions while imports are still untyped. @ts-expect-error itself becomes an error once the line below stops erroring, so fixing the underlying type forces you to delete the suppression. @ts-ignore never reports anything, so it lingers after the original error is gone and silently hides a different error introduced later.
Incorrect (@ts-ignore — rots after the underlying error is fixed):
// @ts-ignore — addDays is untyped legacy code
const dueDate = addDays(invoice.issuedAt, 30)
// After addDays gets types, this ignore is pointless but stays, and will
// mask a genuine error introduced on this line months from now.Correct (@ts-expect-error — self-removes once the type lands):
// @ts-expect-error addDays is untyped until the dates module migrates
const dueDate = addDays(invoice.issuedAt, 30)
// Once addDays is typed, this line stops erroring, so @ts-expect-error
// itself errors — TypeScript tells you to delete the now-needless comment.Reference: TypeScript 3.9 Release Notes
Set skipLibCheck to Silence Third-Party Type Noise
Conflicting or outdated @types packages can emit hundreds of errors inside node_modules/**/*.d.ts that have nothing to do with your code, burying the errors you can actually fix. skipLibCheck checks how your code uses declaration files but skips checking the declaration files internally, so the error list reflects your migration, not your dependencies' bugs.
Incorrect (libs checked — your real errors drown in dependency noise):
{
"compilerOptions": {
"strict": true,
"allowJs": true,
"skipLibCheck": false
}
}A single mismatch between @types/express and @types/node versions can print 200+ errors from node_modules, hiding the dozen errors in your src.
Correct (skipLibCheck on — only your usage is checked):
{
"compilerOptions": {
"strict": true,
"allowJs": true,
"skipLibCheck": true
}
}Your code is still fully type-checked against library types; only the libraries' own internal declarations are skipped. Worth re-evaluating once the migration is finished.
Reference: tsconfig: skipLibCheck
Enable strict Flags One at a Time, Not All at Once
Flipping "strict": true on a freshly migrated JavaScript codebase turns on eight checks simultaneously and surfaces thousands of errors at once — far too many to fix in one reviewable change. Enabling a single flag, fixing its errors, and committing converts an unbounded backlog into a sequence of bounded, reviewable batches that keep main green throughout.
Incorrect (full strict at once — thousands of errors in one branch):
{
"compilerOptions": {
"allowJs": true,
"strict": true
}
}strict enables eight flags together. On a 50k-line migration this can be 3,000+ errors in a single branch that is impossible to review or merge.
Correct (ratchet one flag per change):
{
"compilerOptions": {
"allowJs": true,
"noImplicitAny": true,
"strictNullChecks": false
}
}Land noImplicitAny as its own PR, then flip strictNullChecks, then the remaining flags — each a bounded batch. Once all are on, replace them with "strict": true and delete the individual entries.
Reference: tsconfig: strict
Enable exactOptionalPropertyTypes to Separate Missing from Undefined
Without this flag, { nickname?: string } also accepts { nickname: undefined }, erasing the difference between an absent key and an explicit undefined. JavaScript code relies on that distinction for in checks, Object.keys, and JSON serialization, so conflating them lets a "clear this field" intent silently read as "leave it unset."
Incorrect (explicit undefined silently allowed):
interface UserPatch {
nickname?: string
}
function update(patch: UserPatch): void {
// A caller passes { nickname: undefined } meaning "clear it", but code
// using `"nickname" in patch` reads it as "set" — the two paths diverge.
applyPatch(patch)
}Correct (flag forces intent to be explicit):
interface UserPatch {
// With exactOptionalPropertyTypes, this cannot be set to undefined.
nickname?: string
}
function update(patch: UserPatch): void {
applyPatch(patch) // callers must omit the key or pass a real string
}
// To allow clearing a field, model it deliberately as string | null.Reference: tsconfig: exactOptionalPropertyTypes
Enable noImplicitAny to Surface Every Untyped Value
Auto-migrated JavaScript is full of implicit any — untyped parameters, untyped imports, untyped this. Without noImplicitAny these stay invisible and silently disable type checking wherever they flow. With it, each becomes a tracked compile error, converting hidden, unmeasurable debt into an explicit checklist you can finish.
Incorrect (implicit any — the whole calculation is unchecked):
// items and rate are implicitly `any`; item.price could be anything.
function applyTax(items, rate) {
return items.reduce((sum, item) => sum + item.price * rate, 0)
}Correct (noImplicitAny forces real parameter types):
function applyTax(items: LineItem[], rate: number): number {
return items.reduce((sum, item) => sum + item.price * rate, 0)
}Reference: tsconfig: noImplicitAny
Enable noUncheckedIndexedAccess for Index Safety
By default TypeScript types arr[i] and record[key] as the element type even when the index is out of range or the key is absent — a JavaScript footgun the type system otherwise ignores. noUncheckedIndexedAccess adds | undefined to indexed reads, forcing a presence check before use and catching crashes that survive every other strict flag.
Incorrect (indexed access assumed present):
function firstTag(tagsByPost: Record<string, string[]>, postId: string): string {
const tags = tagsByPost[postId] // typed string[], may be undefined
return tags[0].toUpperCase() // two unchecked crashes hide on this line
}Correct (flag forces presence checks):
function firstTag(tagsByPost: Record<string, string[]>, postId: string): string {
const tags = tagsByPost[postId] // now string[] | undefined
const first = tags?.[0] // string | undefined
return first ? first.toUpperCase() : "UNTAGGED"
}When NOT to use this pattern:
- Hot loops over a known-dense array where the bounds are already proven; a
single const value = arr[i]! after an explicit length check is clearer than threading | undefined through every access.
Reference: tsconfig: noUncheckedIndexedAccess
Prioritize strictNullChecks for the Highest Bug Yield
Without strictNullChecks, null and undefined are assignable to every type, so TypeScript cannot catch the single most common JavaScript crash — reading a property of undefined. It is the highest-value flag in any migration; sequence it immediately after noImplicitAny, since enabling it later means re-auditing code you already touched.
Incorrect (strictNullChecks off — undefined access compiles):
function getEmail(users: Map<string, User>, id: string): string {
const user = users.get(id) // typed `User`, but actually `User | undefined`
return user.email // compiles, throws at runtime when id is absent
}Correct (strictNullChecks on — the gap is a compile error):
function getEmail(users: Map<string, User>, id: string): string {
const user = users.get(id) // now typed `User | undefined`
if (!user) throw new Error(`No user for id ${id}`)
return user.email // narrowed to `User`, safe to access
}Reference: tsconfig: strictNullChecks
Type Caught Errors as unknown, Not any
JavaScript code assumes catch (e) hands back an Error and reads e.message, but anything can be thrown — strings, undefined, rejected non-Error values. useUnknownInCatchVariables (on under strict) types the caught value as unknown, forcing you to narrow it before access so a thrown string cannot crash the error handler itself.
Incorrect (assumes Error shape):
try {
await chargeCard(order)
} catch (e: any) {
logger.error(e.message) // throws again when a string or null was thrown
}Correct (narrow unknown before use):
try {
await chargeCard(order)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e)
logger.error(message)
}Reference: tsconfig: useUnknownInCatchVariables
Annotate Exported Function Signatures Explicitly
An exported function's signature is the contract every importer depends on. When the return type is inferred, a change to the body can silently widen that contract and leak an internal type to all callers with no local error. An explicit return type locks the contract, makes the compiler check the body against it, and removes inference work that slows large-project type-checking.
Incorrect (inferred export — return type drifts with the body):
// The return type is inferred. Adding a cache field later silently widens
// the public shape and leaks an internal detail to every caller.
export function loadOrder(id: string) {
const order = db.orders.find(id)
return { ...order, _cacheKey: `order:${id}` }
}Correct (explicit signature — body checked against the contract):
export function loadOrder(id: string): Order {
const order = db.orders.find(id)
return order // the compiler now rejects an accidental extra public field
}Convert Loose Object Arguments to Named Interfaces
A function that takes an ad-hoc inline object gives callers no guidance and produces giant single-line structural error messages on any mismatch. A named interface documents every field, can be exported and reused by callers, and makes the compiler report CreateInvoiceInput instead of an unreadable inline shape.
Incorrect (inline structural type — unreadable, unreusable):
function createInvoice(arg: {
customerId: string
lines: { sku: string; qty: number }[]
dueInDays: number
}): Invoice {
return persistInvoice(arg)
}Correct (named interfaces — reusable, readable errors):
interface InvoiceLine {
sku: string
qty: number
}
interface CreateInvoiceInput {
customerId: string
lines: InvoiceLine[]
dueInDays: number
}
function createInvoice(input: CreateInvoiceInput): Invoice {
return persistInvoice(input)
}Reference: Google TypeScript Style Guide
Replace JSDoc Type Tags with Real Annotations
Once a file is .ts, JSDoc type tags like @param {string} duplicate the real signature and tsc ignores them entirely — so they drift the moment the signature changes, leaving two contradicting sources of truth. Move the type into the annotation and keep JSDoc for prose only: descriptions, @example, @deprecated.
Incorrect (JSDoc types in a .ts file — silently drift from the code):
/**
* @param {string} sku
* @param {number} qty
* @returns {number}
*/
function lineTotal(sku: string, qty: number): number {
// tsc ignores the JSDoc types; if qty later becomes a string the JSDoc
// still claims number and no one notices.
return priceOf(sku) * qty
}Correct (annotations are the single source of truth):
/** Total cost in cents for a quantity of one SKU. */
function lineTotal(sku: string, qty: number): number {
return priceOf(sku) * qty
}Reference: Migrating from JavaScript
Type Callback and Higher-Order Parameters
An untyped callback parameter is implicitly any, which spreads to every handler body and every caller — defeating type checking across the entire call graph that flows through it. Typing the callback signature restores inference for all subscribers at once, so each handler is checked against the real event shape.
Incorrect (untyped callback — every handler is unchecked):
// handler is implicitly any, so event and its fields are unchecked in
// every subscriber registered anywhere in the codebase.
function onPayment(handler) {
bus.subscribe("payment", handler)
}Correct (typed callback signature):
interface PaymentEvent {
orderId: string
amountCents: number
}
function onPayment(handler: (event: PaymentEvent) => void): void {
bus.subscribe("payment", handler)
}Reference: TypeScript Handbook: More on Functions
Declare Class Field Types Instead of Relying on Assignment
TypeScript infers a class field's type from constructor assignments, but fields set conditionally, in methods, or by a framework become implicit any or error under strictPropertyInitialization. Explicit field declarations make the class shape complete and strict-safe, and document the object's structure in one place instead of scattered across methods.
Incorrect (fields established only by assignment):
class OrderProcessor {
constructor(gateway) {
this.gateway = gateway // gateway field is implicitly any
}
attachLogger(logger) {
this.logger = logger // logger appears only here, so its type is any
}
}Correct (explicit field declarations):
class OrderProcessor {
private readonly gateway: PaymentGateway
private logger?: Logger
constructor(gateway: PaymentGateway) {
this.gateway = gateway
}
attachLogger(logger: Logger): void {
this.logger = logger
}
}Reference: TypeScript Handbook: Classes
Type Default and Optional Parameters Precisely
JavaScript hides an options object behind opts = opts || {}, leaving callers to guess which keys are accepted and the compiler unable to check any of them. Typed optional parameters with destructured defaults expose the exact shape, give callers autocomplete, and let the compiler validate every field they pass.
Incorrect (untyped options blob — callers guess the keys):
// opts is implicitly any; nothing tells a caller what retry() accepts.
function retry(task, opts) {
const max = (opts && opts.max) || 3
const delay = (opts && opts.delay) || 100
return runWithRetry(task, max, delay)
}Correct (typed optional parameter with defaults):
interface RetryOptions {
max?: number
delay?: number
}
function retry(task: () => Promise<void>, opts: RetryOptions = {}): Promise<void> {
const { max = 3, delay = 100 } = opts
return runWithRetry(task, max, delay)
}Reference: Effective TypeScript
Provide Ambient Declarations for Untyped Dependencies
Importing a JavaScript-only package with no bundled or community types errors under noImplicitAny ("Could not find a declaration file"). Casting the whole import to any unblocks it but disables checking for everything that package exports, forever. A declare module stub scopes the gap to the exact functions you use and marks the dependency for proper typing later.
Incorrect (cast the module to any — checking off for all of it):
// Disables type checking for everything legacy-charts exports.
const charts = require("legacy-charts") as any
charts.render(el, series)Correct (ambient declaration scopes and types the gap):
// types/legacy-charts.d.ts
declare module "legacy-charts" {
export function render(el: HTMLElement, series: ChartSeries[]): void
}
// chart-view.ts — now typed and import-based
import { render } from "legacy-charts"
render(el, series)Reference: TypeScript Handbook: Declaration Files
Emit Declaration Files for Migrated Libraries
A migrated library that publishes only compiled JavaScript forces every consumer back to any, discarding the types you spent the migration adding. Setting declaration: true emits .d.ts files, and pointing the package's types field at them publishes the contract so downstream projects keep type-checking against your library.
Incorrect (ship JS only — consumers lose all types):
{
"compilerOptions": {
"declaration": false,
"outDir": "dist"
}
}With package.json "main": "dist/index.js" and no "types" field, every importer of this library gets any.
Correct (emit and publish declarations):
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}Add "types": "dist/index.d.ts" to package.json so consumers resolve the emitted declarations; declarationMap lets their editors jump to your source.
Reference: tsconfig: declaration
Install types Packages Before Casting Library Returns
A large share of JavaScript libraries ship community type definitions on DefinitelyTyped as @types/* packages that provide full signatures for free. Reaching for as or any to silence a missing-types error throws away type information you could install in one command — and the cast then hides real misuse the signatures would have caught.
Incorrect (casting around types that actually exist):
const lodash = require("lodash") as any
const unique = lodash.uniqBy(orders, "customerId") // unique is anyCorrect (install the @types package, get full signatures):
// npm install -D @types/lodash
import { uniqBy } from "lodash"
const unique = uniqBy(orders, (order) => order.customerId) // fully typedCheck for types with npm view @types/<package> before writing any cast.
Reference: DefinitelyTyped
Add a Type-Check Step to CI Separate from the Build
Bundlers like esbuild, swc, and Vite strip types without checking them, so a green build can still contain the exact type errors your migration set out to eliminate. A dedicated tsc --noEmit step is the only CI gate that actually enforces the types you added — without it, strictness flags you turned on are advisory.
Incorrect (build is the only gate — types never checked):
{
"scripts": {
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js",
"ci": "npm run build"
}
}esbuild transpiles and discards types, so type errors sail through CI unseen.
Correct (a separate type-check gate runs first):
{
"scripts": {
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js",
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm run build"
}
}Reference: tsconfig: noEmit
Run TypeScript Directly with tsx Instead of ts-node Flags
ts-node needs brittle loader flags (--loader ts-node/esm, --esm, experimental specifier resolution) that break across Node versions and module settings — a recurring time sink mid-migration when the module system is in flux. tsx, built on esbuild, runs .ts files directly with both ESM and CommonJS support and no per-run type-check cost, so dev scripts stop fighting the loader.
Incorrect (ts-node with brittle ESM flags):
{
"scripts": {
"dev": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server.ts"
}
}This breaks whenever Node deprecates --loader or the project's module setting changes, forcing another round of flag archaeology.
Correct (tsx runs the file directly):
{
"scripts": {
"dev": "tsx watch src/server.ts"
}
}tsx only transpiles, so keep a separate tsc --noEmit step for type checking.
Reference: tsx documentation
Avoid Double Assertions That Force Unrelated Types
value as unknown as Target defeats every safety check the compiler offers and is a reliable migration smell — it appears wherever someone forced a stubborn error to go away. It hides a genuine mismatch between the real value and the asserted type, which then surfaces as a runtime crash that the type checker swore could not happen.
Incorrect (double assertion forces an incompatible type):
// session actually has { uid }, but this forces it to User and then reads
// fields that do not exist at runtime.
const user = session as unknown as User
sendWelcome(user.email) // user.email is undefined at runtimeCorrect (map the real shape to the target explicitly):
function toUser(session: Session): User {
return {
id: session.uid,
email: lookupEmail(session.uid),
}
}
const user = toUser(session)
sendWelcome(user.email)Reference: Effective TypeScript
Replace as Casts with Narrowing or Validation
An as cast is an unchecked promise to the compiler with zero runtime verification — precisely the tool a JavaScript migration over-uses to silence errors fast. The error goes away but the wrong shape still arrives at runtime and crashes later, far from the cast. Narrowing or schema validation confirms the claim, so the type you assert is actually true.
Incorrect (cast silences the error but verifies nothing):
function handle(req: Request): void {
const body = req.body as CheckoutPayload // unverified; wrong shape crashes later
charge(body.cardToken, body.amountCents)
}Correct (validate, so the type is real at runtime):
function handle(req: Request): void {
const body = CheckoutPayloadSchema.parse(req.body) // throws on a bad shape
charge(body.cardToken, body.amountCents)
}Narrow Values Instead of Using the Non-Null Assertion
The ! non-null assertion silences strictNullChecks without any proof, reintroducing exactly the cannot read property of undefined crashes the flag exists to prevent. Sprinkling ! to clear migration errors trades a compile error for a runtime one. A guard or early return proves non-nullness to both the compiler and the runtime.
Incorrect (non-null assertion — unproven, crashes when wrong):
function greet(users: Map<string, User>, id: string): string {
return `Hi ${users.get(id)!.name}` // throws when id is absent
}Correct (narrow with a guard):
function greet(users: Map<string, User>, id: string): string {
const user = users.get(id)
if (!user) return "Hi there"
return `Hi ${user.name}`
}When NOT to use this pattern:
- Right after an existence check the compiler cannot follow across a helper
boundary. Even then, prefer a custom assertion function (assertExists) over a bare !, so the check runs at runtime too.
Reference: tsconfig: strictNullChecks
Replace any with unknown at Untrusted Boundaries
any disables every check and propagates silently to everything it touches, so one any parameter quietly un-types its whole call chain. unknown keeps the value opaque until you narrow it, forcing safe handling at the boundary. Auto-migration scatters any across function inputs — converting each to unknown is the single change that re-enables type safety downstream.
Incorrect (any spreads from the boundary outward):
function parseMessage(raw: any): QueueMessage {
// Every access on raw is unchecked, and the any leaks into the result.
return { id: raw.id, body: raw.payload.body }
}Correct (unknown forces narrowing before use):
function parseMessage(raw: unknown): QueueMessage {
if (typeof raw !== "object" || raw === null || !("id" in raw)) {
throw new Error("Malformed queue message")
}
// raw is now narrowed; validate the remaining fields before returning
return QueueMessageSchema.parse(raw)
}Reference: TypeScript Handbook: unknown
Replace the Function Type with Specific Call Signatures
The bare Function type accepts any arguments and returns any, so every call through it is unchecked — a frequent crutch when migrating callback registries and event maps. A precise (arg: T) => R signature restores argument and return checking at every call site, catching wrong arity and wrong types the Function type waves through.
Incorrect (Function type — calls check nothing):
// Each value is `Function`; calling it verifies neither arity nor types.
const handlers: Record<string, Function> = {}
function dispatch(type: string, payload: unknown): void {
handlers[type](payload) // wrong arity or argument type fails silently
}Correct (explicit call signature):
type Handler = (payload: unknown) => void
const handlers: Record<string, Handler> = {}
function dispatch(type: string, payload: unknown): void {
handlers[type]?.(payload) // arity and argument type are now checked
}Reference: Google TypeScript Style Guide
Type Dynamic Property Access with Records or Index Signatures
JavaScript routinely uses a plain object as a map (obj[key] = value), which under noImplicitAny becomes an implicit-any or element-access error. Reaching for any to silence it throws away the value type. A Record<K, V> or index signature types the dynamic access while keeping the value's type checked.
Incorrect (object-as-map triggers any and element errors):
// counts is implicitly any; every read and write is unchecked.
const counts = {}
for (const event of events) {
counts[event.type] = (counts[event.type] || 0) + 1
}Correct (typed as a Record):
const counts: Record<string, number> = {}
for (const event of events) {
counts[event.type] = (counts[event.type] ?? 0) + 1
}Alternative (Map for genuinely unbounded dynamic keys):
const counts = new Map<string, number>()
for (const event of events) {
counts.set(event.type, (counts.get(event.type) ?? 0) + 1)
}Reference: TypeScript Handbook: Index Signatures
Related skills
FAQ
What does migrate-js-to-modern-typescript do?
migrate-js-to-modern-typescript is a Claude Code skill in the AI & Agent Building category.
When should I use migrate-js-to-modern-typescript?
When you need to helps with ai & agent building tasks during ai-assisted development, or when migrate-js-to-modern-typescript is a claude code skill in the ai & agent building category.
What are the main capabilities?
migrate-js-to-modern-typescript; AI & Agent Building; AI-coding skill.