Migration & Adoption
Bringing TypeScript into an existing codebase — gradual file-by-file adoption, @ts-expect-error versus @ts-ignore, rolling out strictNullChecks on a large project, enforcing conventions in CI, and reviewing a PR full of any.
6 questions
JuniorTheoryVery commonHow do you introduce TypeScript into a large existing JavaScript codebase?
How do you introduce TypeScript into a large existing JavaScript codebase?
Turn on allowJs so .js and .ts compile side by side, then rename file by file — leaf modules first, hot shared paths last. checkJs plus JSDoc types checks the files not renamed yet. Ratchet strict flags up only once most code is .ts.
Common mistakes
- ✗Believing a project cannot mix
.jsand.tsin a single build - ✗Turning on
strictat the start, which buries the migration in errors before anything is typed - ✗Overlooking
checkJsplus JSDoc as a way to type files before renaming them
Follow-up questions
- →Which files would you convert first, and why not start with the shared core?
- →What does
allowJschange about what the compiler actually emits?
JuniorTheoryCommonWhat is the difference between @ts-expect-error and @ts-ignore?
What is the difference between @ts-expect-error and @ts-ignore?
Both suppress the error on the next line, but @ts-expect-error requires one to be there: once the line stops erroring, the directive errors and must go. @ts-ignore never complains, survives the fix, and rots into a blind spot nobody revisits.
Common mistakes
- ✗Thinking the two behave identically once written, differing only in their name
- ✗Treating
@ts-ignoreas tracked debt when nothing ever forces anyone to revisit it - ✗Assuming a suppression comment affects the whole file rather than just the next line
Follow-up questions
- →How would you force every suppression in the codebase to carry a written reason?
- →Why does
@ts-expect-errorbecome an error once the code beneath it is fixed?
MiddleDesignCommonYou are reviewing an 800-line pull request from a colleague who is new to TypeScript. It works and the tests pass. It also contains eleven any annotations, four @ts-ignore comments above lines that look fine at a glance, several as Config assertions on data that arrives from fetch, and one as unknown as Session. The author is under deadline pressure and is already frustrated with the type system. Explain how you would triage these constructs — which ones you would block the merge on, which you would accept with a follow-up, and what you would change in the codebase or in the process so that the next pull request from this author does not look the same.
You are reviewing an 800-line pull request from a colleague who is new to TypeScript. It works and the tests pass. It also contains eleven any annotations, four @ts-ignore comments above lines that look fine at a glance, several as Config assertions on data that arrives from fetch, and one as unknown as Session. The author is under deadline pressure and is already frustrated with the type system. Explain how you would triage these constructs — which ones you would block the merge on, which you would accept with a follow-up, and what you would change in the codebase or in the process so that the next pull request from this author does not look the same.
Triage by blast radius, not count. as on fetch data is unchecked at run time — block it, validate at the boundary. Each @ts-ignore becomes a described @ts-expect-error, dying with the error. any on a local can wait, on an export cannot.
Common mistakes
- ✗Treating an
asassertion on external data as type safety rather than an unchecked claim about run time - ✗Weighing the suppressions by count instead of by the surface area each of them exposes
- ✗Accepting
@ts-ignorewhere a described@ts-expect-errorwould self-destruct once the error is fixed
Follow-up questions
- →How would you type the
fetchboundary so that no assertion is needed there at all? - →What would you add to CI so that the next pull request cannot repeat this?
MiddleDesignCommonA team of twelve keeps shipping code that compiles but quietly gives up on types: any on the boundaries, @ts-ignore above inconvenient lines, and objects reshaped with as instead of being validated. The build stays green, so nothing blocks a merge, and the erosion stays invisible until someone hits a runtime crash inside a supposedly typed code path. Design the CI enforcement that would make this class of erosion impossible to merge unnoticed, and explain how you would introduce it on a codebase that already contains hundreds of such violations without stopping feature work.
A team of twelve keeps shipping code that compiles but quietly gives up on types: any on the boundaries, @ts-ignore above inconvenient lines, and objects reshaped with as instead of being validated. The build stays green, so nothing blocks a merge, and the erosion stays invisible until someone hits a runtime crash inside a supposedly typed code path. Design the CI enforcement that would make this class of erosion impossible to merge unnoticed, and explain how you would introduce it on a codebase that already contains hundreds of such violations without stopping feature work.
tsc is the floor: any, as and @ts-ignore compile cleanly. Lint catches them: no-explicit-any, no-unsafe-*, ban-ts-comment — banning @ts-ignore, allowing @ts-expect-error only with a reason. Ratchet in: baseline, fail CI on a rise.
Common mistakes
- ✗Believing
tscorstrictrejectsany,asand@ts-ignore— all of them compile cleanly - ✗Gating on zero violations from day one, which stops feature work instead of reversing the trend
- ✗Suppressing the existing violations in a way that hides them from the rule rather than baselining them
Follow-up questions
- →Which lint rules need type information to work, and what does that cost you in CI?
- →How would you keep the baseline from being edited upwards without anyone noticing?
MiddleDesignCommonYour team owns a 250k-line TypeScript codebase that has run with strictNullChecks: false since it was written. Turning the flag on in a branch produces about 4,000 errors across 600 files, and product work cannot pause while they are fixed. You need to reach strictNullChecks: true without a long-lived branch, without a big-bang merge, and without letting newly written code add fresh violations while the backlog is burned down. Describe how you would sequence the rollout, how you would stop the files you have already cleaned from regressing, and how you would decide when a real null check is the fix and when a non-null assertion is acceptable.
Your team owns a 250k-line TypeScript codebase that has run with strictNullChecks: false since it was written. Turning the flag on in a branch produces about 4,000 errors across 600 files, and product work cannot pause while they are fixed. You need to reach strictNullChecks: true without a long-lived branch, without a big-bang merge, and without letting newly written code add fresh violations while the backlog is burned down. Describe how you would sequence the rollout, how you would stop the files you have already cleaned from regressing, and how you would decide when a real null check is the fix and when a non-null assertion is acceptable.
Per-file campaign, not a flag flip. A growing strict tsconfig covers new and cleaned files; a ratchet fails CI on a rise. Suppress the 4,000 and burn them down in ordinary PRs. Check for absent values; ! only for a true, unprovable invariant.
Common mistakes
- ✗Treating it as a flag flip rather than a per-file campaign with a long backlog behind it
- ✗Reaching for
!to clear errors quickly, which hides the absent values instead of handling them - ✗Assuming a cleaned file cannot regress once the flag is on, with nothing enforcing the ratchet
Follow-up questions
- →How do you keep a file that has been cleaned from silently regressing in a later pull request?
- →When is a non-null assertion the honest answer rather than a shortcut?
SeniorDesignOccasionalA senior engineer on your team has written a 60-line helper of conditional and mapped types that infers a route handler's params, body and response directly from the route string. It is correct, and it has already caught two real bugs. It also produces a forty-line error message whenever anything is slightly off, nobody else on the team can modify it, and the editor takes about two seconds to offer completions in the files that use it. The engineer now wants to apply the same technique to the data layer. Take a position on this trade-off, and explain how you would decide in general when a clever type earns its keep and when it should be replaced by something simpler plus a runtime check.
A senior engineer on your team has written a 60-line helper of conditional and mapped types that infers a route handler's params, body and response directly from the route string. It is correct, and it has already caught two real bugs. It also produces a forty-line error message whenever anything is slightly off, nobody else on the team can modify it, and the editor takes about two seconds to offer completions in the files that use it. The engineer now wants to apply the same technique to the data layer. Take a position on this trade-off, and explain how you would decide in general when a clever type earns its keep and when it should be replaced by something simpler plus a runtime check.
Price a type by its cost to the team — error messages, compile time, bus factor — not what it proves. A clever one pays only behind a stable named boundary, consumed as an ordinary type. Where proof is dear, a boring type plus a boundary check wins.
Common mistakes
- ✗Judging a type by what it proves rather than by what it costs the team that has to maintain it
- ✗Treating a compile-time guarantee as strictly better than a boundary check plus a simple type
- ✗Banning clever types outright instead of confining them behind a stable named boundary
Follow-up questions
- →What would change your answer if the helper were published as a library rather than kept in-house?
- →How would you measure the editor and build cost this helper actually imposes today?