Migrating JavaScript to TypeScript
10 questions found
What is the recommended first step when migrating an existing JavaScript project to TypeScript?
Beginner
Add a tsconfig.json with 'allowJs': true and 'checkJs': false (or start with a very lenient config), rename files gradually from .js to .ts rather than all at once, and let the project compile alongside existing JavaScript during the transition.
// tsconfig.json — lenient starting point
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false,
"noImplicitAny": false
}
}
Real-world example
Migrating a large legacy Express or React codebase file-by-file over several sprints instead of a risky big-bang rewrite.
Common follow-ups: Why is a gradual, file-by-file migration generally safer than converting an entire codebase at once?
tsconfig & Compiler Options
What does the 'allowJs' compiler option do, and why is it useful during migration?
Beginner
allowJs lets the TypeScript compiler process plain .js files alongside .ts files in the same project, so you can incrementally rename files to .ts one at a time while the rest of the codebase remains JavaScript and still compiles/type-checks together.
// tsconfig.json
{
"compilerOptions": { "allowJs": true }
}
// Both utils.js and newFeature.ts can coexist and be imported from each other
Real-world example
Allowing a new feature to be written in TypeScript from day one while the surrounding legacy code stays in plain JavaScript.
Common follow-ups: What's the difference between 'allowJs' and 'checkJs'?
tsconfig & Compiler Options
How does the 'checkJs' option (combined with '// @ts-check') let you type-check plain JavaScript files using JSDoc comments?
Intermediate
With checkJs enabled (or a per-file '// @ts-check' comment), TypeScript type-checks .js files using JSDoc-style comments as the source of type information, letting you get real type errors and autocomplete on JavaScript files without converting them to .ts syntax at all.
// @ts-check
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
function add(a, b) {
return a + b;
}
add('1', 2); // Error caught even in a plain .js file
Real-world example
Adding meaningful type safety to legacy JavaScript files that can't easily be renamed to .ts yet, without a full rewrite.
Common follow-ups: Which JSDoc tags does TypeScript understand for describing more complex types, like unions or generics?
Declaration Files
When migrating, why is it common to start with a lenient tsconfig and gradually tighten strictness flags rather than enabling 'strict': true immediately?
Intermediate
Enabling full strict mode on an existing large JavaScript codebase typically surfaces hundreds or thousands of pre-existing implicit-any and null-safety issues all at once, which is overwhelming to fix in one pass — gradually enabling individual strict flags (starting with noImplicitAny, then strictNullChecks, etc.) lets a team fix issues incrementally without blocking all other work.
// Migration path over several stages:
// Stage 1: { "strict": false, "noImplicitAny": true }
// Stage 2: add { "strictNullChecks": true }
// Stage 3: eventually { "strict": true }
Real-world example
Rolling out strict mode across a large team's codebase over several sprints instead of one disruptive, blocking change.
Common follow-ups: Which individual strict-mode sub-flags tend to surface the most errors first in a typical migration?
tsconfig & Compiler Options
How do you handle a third-party JavaScript dependency that has no available type definitions during migration?
Intermediate
Either install a community @types package if one exists, or write a minimal ambient declaration yourself (a .d.ts file with `declare module 'library-name';`), which effectively types the whole module as 'any' — unblocking compilation while leaving room to add real types later.
// untyped-lib.d.ts
declare module 'untyped-lib';
// usage.ts
import untypedLib from 'untyped-lib'; // typed as 'any', but no longer a compile error
Real-world example
Unblocking a migration when a legacy internal or niche third-party package has no available type definitions.
Common follow-ups: What's the risk of leaving a dependency typed as 'any' indefinitely, rather than eventually writing real types for it?
Declaration Files
How would you incrementally enable 'noImplicitAny' across a large codebase without fixing every error immediately?
Advanced
Enable noImplicitAny globally, but use the 'exclude' array (or per-directory tsconfig overrides via project references / separate tsconfig files) to temporarily exempt not-yet-migrated directories, then progressively shrink that exclusion list as more of the codebase is cleaned up.
// tsconfig.json
{
"compilerOptions": { "noImplicitAny": true },
"exclude": ["src/legacy/**"] // temporarily exempted, shrinks over time
}
Real-world example
Tracking and reducing a migration's 'debt' as a shrinking exclude list that the team steadily chips away at.
Common follow-ups: How could you track migration progress numerically, like percentage of files fully typed?
tsconfig & Compiler Options
What common runtime-vs-type mismatch bugs does migrating to TypeScript often surface in previously 'working' JavaScript code?
Advanced
Migration frequently reveals latent bugs that JavaScript's dynamic typing silently allowed: functions that don't always return a value on every code path, values assumed to never be null/undefined that actually can be, and array/object shapes assumed consistent that actually vary — all previously invisible until the type checker starts enforcing consistency.
function getDiscount(user) { // plain JS, silently 'worked'
if (user.isPremium) return 0.2;
// no else branch -- implicitly returns undefined, used as a number elsewhere!
}
// TypeScript: function getDiscount(user): number is flagged, since not all paths return a number
Real-world example
Uncovering a genuine, previously-silent bug where a function occasionally returned undefined and it went unnoticed for months.
Common follow-ups: Is it common for a TypeScript migration to actually catch real production bugs like this, beyond just adding type annotations?
Never
Unknown & Void Types
How do you convert PropTypes-based type checking (common in older React codebases) into equivalent TypeScript types during migration?
Advanced
Translate each PropTypes validator into an equivalent TypeScript interface member: PropTypes.string.isRequired becomes `name: string`, PropTypes.func becomes an optional function type, PropTypes.shape({...}) becomes a nested interface — then remove the runtime PropTypes checks entirely, since TypeScript now enforces the same contract at compile time (with the trade-off of losing PropTypes' runtime warnings for genuinely untyped external data).
// Before (PropTypes)
UserCard.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func
};
// After (TypeScript)
interface UserCardProps {
name: string;
onClick?: () => void;
}
function UserCard({ name, onClick }: UserCardProps) { /* ... */ }
Real-world example
Migrating a legacy React component library from runtime PropTypes validation to compile-time TypeScript prop types.
Common follow-ups: What capability does PropTypes retain that pure compile-time TypeScript types lose, given TypeScript types don't exist at runtime?
Types & Interfaces
How would you use TypeScript's 'unknown' type, rather than 'any', when initially typing uncertain data during migration to preserve safety?
Advanced
Type genuinely uncertain values (like a JSON.parse() result or an external API response of unclear shape) as 'unknown' instead of 'any' — this forces you to explicitly narrow or validate the value before using it, catching potential migration mistakes, whereas 'any' silently disables all checking and can let bugs slip through unnoticed.
function parseConfig(json: string): unknown {
return JSON.parse(json);
}
const config = parseConfig(rawInput);
// config.someProperty; // Error: 'config' is of type 'unknown' -- must narrow first
if (typeof config === 'object' && config !== null && 'theme' in config) {
console.log((config as { theme: string }).theme); // now safe
}
Real-world example
Migrating a data-parsing layer where the exact shape of legacy JSON data isn't fully trusted or known yet.
Common follow-ups: Why is defaulting to 'any' during migration considered a common anti-pattern that undermines the whole point of migrating?
Never
Unknown & Void Types
How do you set up a monorepo migration strategy using TypeScript project references so partially-migrated packages don't block fully-typed ones?
Advanced
Use TypeScript's 'references' field in tsconfig.json to declare explicit build dependencies between packages, allowing fully-migrated packages to be built and strictly type-checked independently and incrementally, while less-migrated packages can retain looser settings in their own tsconfig without holding back the rest of the monorepo's build pipeline.
// packages/core/tsconfig.json
{
"compilerOptions": { "composite": true, "strict": true },
"references": []
}
// packages/app/tsconfig.json
{
"compilerOptions": { "strict": false },
"references": [{ "path": "../core" }]
}
Real-world example
Migrating a large monorepo where core shared libraries are fully strict-typed first, while application code catches up gradually.
Common follow-ups: What build performance benefit do project references provide beyond just organizing migration strictness levels?
tsconfig & Compiler Options