From 342,000 Lines of JavaScript to TypeScript: How We Migrated Handsontable’s Library
Handsontable is one of the most feature-rich data grid libraries on the web. It powers financial dashboards, medical records systems, enterprise ERPs, and low-code platforms across thousands of production applications. For years, it was written in JavaScript — and for most of that time, the idea of converting it to TypeScript was something we openly wanted but kept postponing.
This is the story of a migration that had been on our roadmap, in our retrospectives, and in informal hallway conversations for longer than most of us can clearly remember. It’s also the story of why, after all those years of “not yet,” we were finally able to ship it: 342,000 lines of JavaScript moved to TypeScript, 235 hand-authored type declarations replaced by compiler-generated ones, and zero modifications to our end-to-end tests along the way.
It’s also a two-chapter story. The first chapter was the initial conversion — a heroic push by one engineer to get the entire codebase compiling under TypeScript. The second chapter was the polish: a senior developer picking up the branch and doing the careful, systematic work that turns “it compiles” into “it’s ready to ship.”
The Migration We Kept Postponing
Inside Handsoncode, “we should move Handsontable to TypeScript” was one of those topics that came back almost on a schedule. The topic surfaced in roadmap planning. It came up after every type-related bug report from a customer. Engineers raised it every time a new hire joined the team and asked, in some form of mild surprise, why a library this large was still in plain JavaScript.
The answer was always the same set of reasons — and the reasons were genuine, not excuses:
The Four Real Blockers
- The codebase was big. 342,000 lines of source, ~1,000 files, a rendering engine (Walkontable), and three official framework wrappers (React, Angular, Vue 3). A naive conversion would either take a senior team a quarter or generate a wall of type errors that no one had time to drive to zero.
- The architectural surface kept the cost high. Handsontable’s
Coreis implemented as a JavaScript function rather than an ES class. The plugin system relies on mixin patterns. The cell meta/settings system uses prototype-chain tricks that TypeScript’s structural type system cannot infer. Whatever path we eventually took, the architecture meant the work could never be automated end-to-end — there would always be a non-trivial tail of manual conversion. - The cost-to-value math never quite closed. Every quarter, we could either ship features customers were asking for, or take engineers off the roadmap to do a migration whose benefits were mostly internal. Customers don’t file tickets asking for a
tsc-clean compile. They file tickets asking for new features and faster bug turnaround. - The risk was real. Handsontable runs inside applications where a regression isn’t a minor inconvenience — it’s a financial system showing wrong numbers, a medical form refusing to save, an ERP screen freezing during a customer demo. A migration that introduced behavioral drift would be felt immediately, by people who matter to us.
By early 2025, after a string of internal attempts that had each stalled for one of the reasons above, the most concrete external benchmark we had came from a contractor we met at JSNation. He offered the migration in 30 days for a $25,000 success fee — “simple, just DeepSeek V3 and a good prompt.” After a paid PoC the price held. The math didn’t justify it against the internal alternatives, and the slogan didn’t address the architectural concerns that had defeated every prior approach. That the quote didn’t pencil out was a data point about the problem itself: the work was being systematically underestimated from the outside. (A fuller account of the four prior attempts — internal manual conversion, an aborted YOLO session in Cursor, a colleague’s structured POC, and the JSNation quote — is in this conference talk slide.)
Ultimately, the migration kept getting deferred. Not because anyone disagreed it was the right thing to do — everyone agreed — but because the path from where we were to where we wanted to be was always a little longer than the time we had.
However, what changed wasn’t the codebase. What changed was the tooling.
The Problem: Type Drift at Scale
While the migration sat on the wishlist, the cost of not doing it kept compounding. Handsontable has shipped TypeScript type declarations since version 8 — but our team wrote those 235 .d.ts files in handsontable/types/ by hand. A developer would change a method signature in JavaScript, and the type declaration would silently stay behind, still describing the old behavior. Users would get tsc --noEmit passing on their side while runtime exceptions were waiting for them in production.
Moreover, type drift is silent, cumulative, and extremely hard to audit at this scale. It’s the kind of cost that doesn’t show up on a single PR but shows up across years. By the time we started this project, a systematic comparison between the hand-authored types and the actual implementations revealed 8 categories of mismatches: incorrect return types, optional parameters incorrectly marked required, missing overloads, and methods present in the implementation that were completely absent from the declarations.
Consequently, we had been patching this manually for years. Every time a customer reported a type mismatch, someone would update a .d.ts file. Every time we shipped a new method, someone had to remember to add its declaration. The system worked — barely — and it worked because individual engineers were carrying the cost of remembering things the compiler should have been remembering for them.
Ultimately, the only long-term solution was to make the compiler the author of the type declarations — not a human. Which meant the migration we kept postponing was no longer a “nice to have.” It was the only path that scaled.
The First Attempt: What the POC Taught Us
Before committing the entire engineering team to a multi-month effort, we ran a proof-of-concept to answer one specific question: had AI coding tools actually changed the economics of this migration, or were we about to repeat the same mistakes that had sunk earlier conversations?
We reduced the codebase to a representative subset (49,000 lines instead of the full 342,000) and ran Claude 3.7 Sonnet in Cursor’s Agent mode. The AI converted the JavaScript to TypeScript. Then we enabled noImplicitAny and ran tsc.
~900 type errors.
What 900 Errors Revealed
The errors were not random. They clustered around exactly the patterns we’d been worried about for years: the Core class implemented as a JavaScript function rather than an ES class, mixin patterns that don’t map naturally to TypeScript’s type system, and prototype chain tricks in the meta/settings system that no automated tool can infer. The AI had done a good job on the easy 80% — but the hard 20% was exactly the architectural surface area that had blocked previous attempts.
Consequently, that was the moment the project turned from a fear into a plan. The 80% the AI handled well was the 80% that had previously required the most tedious engineering hours. The 20% it couldn’t handle was the part that required senior judgment — and that 20% was something we could actually staff and finish in a reasonable timeframe. The economics had finally shifted.
The key takeaways from the POC:
- This is not a task you hand entirely to an AI agent. AI accelerates the work significantly, but a developer must plan, coordinate, and handle the key architectural decisions.
- Converting the whole codebase in one pass creates an unmanageable error pile. The right approach is file by file, with the type-checker green after each file.
- The
types/directory is a valuable starting point — comparing compiler-generated declarations against the hand-authored ones is a built-in way to validate progress.
In turn, this POC result shaped every decision we made in the actual migration.
The Strategy: AI-Assisted, Developer-Led, File by File
As a result, the migration strategy came directly from the POC’s lessons:
- Set up the TypeScript build pipeline first — get the project compiling before converting any logic.
- Create the key types and interfaces by hand — use the existing
types/directory as a blueprint. - Handle non-idiomatic code deliberately — identify the patterns that AI can’t convert correctly and rewrite them manually before the bulk conversion.
- Convert the rest file by file, AI-assisted — once the foundation is solid, the remaining ~80% is straightforward for an AI assistant.
- Tests are the behavioral contract (ADR-6). If a test needed to change, the migration was wrong, not the test. Zero modifications to any
.spec.jsE2E test file.
This is also why the migration was possible to do in parallel with ongoing development on develop. The branch absorbed 50 commits from develop during the initial phase, with 127 conflict resolutions — every one of which was an opportunity to verify that the TypeScript version and the JavaScript version behaved identically.
The Technical Architecture
Architecture Decision Records
We captured the load-bearing decisions of this migration as Architecture Decision Records (ADRs) — short documents that record a single technical decision, the rationale behind it, and the consequences. Six ADRs shaped the migration:
- ADR-1: Incremental TypeScript strictness. Enable
noImplicitAnyfirst; defer the rest ofstrict: trueto follow-up PRs rather than fight the entire strict-checks surface in one go. - ADR-2: Build pipeline migration to rspack +
builtin:swc-loader. The branch predated thedeveloprspack migration, and sincebabel-loaderwas no longer installed in the package, we had to migrate. - ADR-3: CommonJS/ESM compilation via
swc-transpile.mjs.@babel/cliwas likewise no longer installed; the build now uses an SWC-based transpile script with per-file parser selection for.js,.ts, and.tsx. - ADR-4: Auto-generated type declarations.
handsontable/types/is now produced bytsc --emitDeclarationOnly, replacing 235 hand-authored.d.tsfiles. - ADR-5: TypeScript version upgrade, 3.8.2 → 5.9.3. A jump across three majors that surfaced 393 type errors across 61 files, all of which were fixed.
- ADR-6: E2E test parity with
develop. Zero modifications permitted to any.spec.jsfile. The test suite is the behavioral contract — if a test needed to change, the migration was wrong, not the test.
Build System (ADR-2 & ADR-3): webpack → rspack, babel-loader → swc
The TypeScript branch predated the develop migration to rspack. Keeping babel-loader was not an option — it wasn’t installed in the package. We migrated the entire build pipeline:
- Bundler:
webpack→rspack - Transpiler:
babel-loader→builtin:swc-loader(separate rules for.jsand.ts/.tsx) - CommonJS/ESM builds:
@babel/cli→node scripts/swc-transpile.mjswith TypeScript support added to the collector and per-file parser selection
The result is a faster build pipeline that handles TypeScript natively. Both build variants — handsontable.js (base) and handsontable.full.js (includes HyperFormula) — compile from TypeScript sources.
TypeScript Version (ADR-5): 3.8.2 → 5.9.3
The project had TypeScript 3.8.2. We upgraded to 5.9.3 — a jump across three major versions. TypeScript 5.x is significantly stricter about implicit any inference, which meant the upgrade surfaced 393 type errors across 61 files that needed fixing before the compiler was clean.
These were not shallow errors. They included:
- Constructor return types that TypeScript 5.x no longer infers from
instanceofchecks - Generic parameter constraints that narrowed differently under stricter inference
- Several places where the hand-authored
types/declarations had diverged and TypeScript now caught the mismatch
All 393 errors were fixed. tsc --noEmit now exits with 0 errors.
Incremental Strictness (ADR-1)
We did not enable strict: true on day one. Enabling all strict checks at once across ~1,000 files would have been the same mistake the POC made — an unmanageable error pile.
The current baseline is noImplicitAny: true with strict: false. We’ll enable the remaining strictness settings incrementally in follow-up PRs, each one isolated to a specific category of type safety. This is deliberate, not a shortcut.
Auto-Generated Type Declarations (ADR-4)
The old handsontable/types/ directory contained 235 hand-authored .d.ts files. They will not be hand-authored anymore. The new pipeline runs tsc --emitDeclarationOnly as part of npm run build:types to generate the declarations automatically from the TypeScript sources.
The compiler is now the single source of truth for types. When a method signature changes, the .d.ts updates automatically on the next build. The 8 categories of type drift we found in the audit no longer exist — and more importantly, they can never silently reappear.
This single change is, internally, the part of the migration we’re most relieved about. Years of manual .d.ts upkeep — a whole category of recurring work — just stopped existing.
The Migration in Numbers
| Metric | Value |
|---|---|
| Source files converted | 1,038 .ts files in src/ |
| Total files changed in PR | 1,456 |
| Lines added | 25,654 |
| Lines removed | 22,095 |
| TypeScript errors fixed (post-upgrade) | 393 across 61 files |
develop commits absorbed | 50 commits, 127 conflict resolutions |
Hand-authored .d.ts files replaced | 235 |
| Type API mismatches found and fixed | 8 categories |
| E2E test files modified | 0 |
tsc --noEmit errors | 0 |
What We Found Along the Way
The Prototype Chain Problem in Filters
One of the subtler bugs uncovered during the migration: { ...cellMeta } creates a plain object with only own enumerable properties. Cell meta objects in Handsontable inherit settings through a three-level prototype chain (CellMeta → ColumnMeta → GlobalMeta). After a spread, the type information (type: 'date', dateFormat) was gone.
The result was that IsBetween date filters were returning all rows instead of the correct subset — because the date branch was never triggered when meta.type was undefined. The fix was to use _getMetaManager().getCellMeta() directly, preserving the prototype chain. A TypeScript migration surfaced a bug that had been lurking in the JavaScript version.
Type API Gaps in the Public Interface
When we replaced hand-authored types with compiler-generated declarations, we found that getActiveEditor() was returning Record<string, unknown> — an effectively useless type. The method returns a live editor instance with beginEditing(), finishEditing(), getValue(), and a dozen other methods that callers need to call. We defined a BaseEditorInstance structural interface in src/common.ts to make the return type actually useful.
Indeed, both findings are exactly the kind that vindicates the project. We didn’t go looking for them — they fell out of the migration. That’s the hidden return on investment of switching to a compiler-authored type system: the library audits itself.
Regression Handling: Staying in Sync with develop
A long-running migration branch is a moving target. develop had 50 commits land during the migration — bug fixes, new features, build pipeline improvements — and every one of them needed to be ported or merged into the TypeScript branch.
Several fixes had been applied to .js files on develop that didn’t automatically apply to the corresponding .ts files on the branch. We audited and ported each one explicitly:
ODD_ROW_CLASSremoval from visual test demos (Angular, React, Vue 3)- Dropdown menu row-span positioning fixes
autoColumnSizeand dropdown menu conversion regressions- The filters
getDataMapAtColumnprototype chain fix (described above)
In practice, the rule was simple: if develop had a fix, the TypeScript branch needed the equivalent fix. The E2E tests enforced this automatically — any behavioral regression showed up as a failing test.
The Polish Phase: Senior Engineering Takes Over
The initial migration — getting 1,038 source files compiling under TypeScript — was the work of a single engineer, Mateusz Wojczal, who pushed the branch from zero to a green compiler in the face of all the architectural complexity described above. That was chapter one.
Chapter two began when Mateusz handed the branch off to Krzysztof “Budzio” Budnik, one of Handsontable’s senior engineers. Budnix, as he’s known on the team, took on the work of turning “it compiles” into “it’s ready to ship.” That distinction matters. A codebase that compiles under noImplicitAny can still be riddled with as casts that bypass the type system, any annotations that opt out of checking entirely, instanceof guards that silently fail in iframe environments, and line-length violations that block CI. Merging a branch in that state would have introduced a new class of technical debt instead of reducing it.
Budnix’s polish phase produced 50 additional commits across roughly 1,381 files, removing 14,420 lines while adding 25,067 — not padding, but real type information replacing assertions and escape hatches. Here is what that work involved, commit by commit.
Security Hardening: Prototype Pollution Guards
The first thing Budnix addressed were two GitHub code scanning alerts flagging prototype-polluting assignments in helpers/object.ts. Both deepMerge and setProperty could be exploited to write to Object.prototype if a key like __proto__ reached them from untrusted input. Budnix added guards — but the fix immediately surfaced a behavioral regression: setAtCell was silently dropping writes when the data row was an array and the column key was a string. The isPlainObject guard added to block prototype pollution was too broad — it was also rejecting valid array targets.
In effect, that chain — security fix → regression → regression fix — is exactly why a careful human review of a migration branch matters. An automated approach would have landed the security fix and never noticed the silent data loss. The fix was to have setAtCell‘s string-column path write directly to arrays via numeric index conversion, consistent with the existing integer-column path.
Systematic as-Cast Reduction: 300+ Casts Eliminated
TypeScript’s as operator is an escape hatch. It tells the compiler “trust me, I know what this is” without any runtime verification. An initial migration that uses AI assistance will produce as casts in all the places where the AI couldn’t infer the correct type — and that’s fine for getting a green compile, but it means a large chunk of the “TypeScript” code is effectively untyped at those sites.
Budnix worked through these systematically across four separate passes, targeting different areas of the codebase:
- Core and plugin cast reduction (~70 casts, 14 files):
filters/constants.ts,customBorders.ts,selection.ts,columnSorting.ts,tableView.ts,formulas.ts. Technique: type guards (isRecord,isFormulasSettingsObject,isFocusPositionObject), generics, overloads, and control-flow narrowing. Notably,formulas.tsalone had 17 casts eliminated. - Plugin and selection cast reduction (~55 casts, 12 files):
comments.ts,dataMap.ts,editors/registry.ts(rewritten as a real class with a typed constructor andisEditorConstructorguard),contextMenu/menu.ts,copyPaste.ts. The context menu alone had 14 casts removed by adding aMenuOptionsinterface and a#getSourceDataAtRowgeneric helper. - Core, helpers, plugins, walkontable (14 files): The border renderer,
table.ts,overlays.ts,element.ts,plugins/base/base.ts,customBorders,mergeCells,themes/engine/builder.ts. Using type guards, generics, and proper control-flow narrowing instead of assertions at call sites. - Plugins and renderers (lint violations fixed alongside):
multiSelectEditor,autofill,comments,emptyDataState,exportFile,filters/component/condition.ts,filters/component/value.ts,mergeCells,search,checkboxRenderer. In several cases,instanceofchecks were replaced with structural property checks to support jQuery Simulate events in E2E tests — a subtle correctness issue that would only manifest under test.
any Type Elimination: 76+ Replaced with Proper Interfaces
Beyond as casts, the initial migration had ~138 remaining any annotations — places where code explicitly opted out of the type system entirely. Budnix replaced the majority of these with precise types across three focused passes:
- Plugins, renderers, settings (~150 annotations, 18 files): Migrated
plugins/comments/viewport.js→viewport.ts(the last plain JavaScript file in the plugin). TypeddataProvider/query/crud.ts,query/filtering.ts,query/sorting.ts, and theexportFile/types/xlsx.tstype system with explicit interfaces. Thexlsx.tsfile alone went from a loose set ofany-typed properties to a properly typed cell-style and formula model. - Walkontable, meta manager, and plugins (76 of 86 remaining): Typed
PositionCache,OrderView,SelectionsContainer, the rendererRendererOptionsinterface,nodesPoolcallback, and command arrays. In the core and meta layer:CellPropertiestyped on renderer/valueFormatter/getter/setter parameters,ModifierClassconstructor type,ColumnMetaon the cell meta layer. The 10 remaininganyusages were documented as justified — load-bearing index signatures or variadic-args positions whereanyis semantically correct. - Final remaining any types:
CellValue: any → unknown(zero cascade, meaning the change propagated safely through all call sites),deepMerge Record<string, any> → Record<string, unknown>,customBorderstyped with explicitBorderObject/BorderSettingsinterfaces,copyPaste populateValuestyped with explicitunknown[][]tuple,columnSorting rootComparatorusing an inline interface instead of aRecord<string, any>cast,baseEditorconstructor dropping itsRecord<string, any>union, andcore.ts activeEditortyped asBaseEditor.
Cross-Realm Safety in Walkontable
One of the more subtle correctness issues in the migration was that the initial conversion had introduced instanceof HTMLElement checks in several walkontable methods — getParent, closest, closestDown, isVisible, offset, getRow, getTrForRow. These checks are valid in a single-realm environment, but Handsontable runs inside iframes in many real-world deployments. In a cross-realm scenario, element instanceof HTMLElement returns false even for valid DOM elements, because the HTMLElement constructor in one realm is not the same object as in another.
Budnix reverted these specific checks to as-casts (the original JavaScript behavior) and used structural property checks (nodeType === 1, !(trimmingElement instanceof HTMLElement) for Window narrowing) where the intent is type narrowing rather than runtime validation. Additionally, Budnix exported the isPlainObject function in object.ts as a type predicate and added a prototype-pollution guard — a single change that served both the security story and the type system.
Code Quality: Line Length, JSDOC, and Lint
Handsontable enforces a 120-character line length limit. The initial TypeScript conversion had introduced hundreds of lines exceeding this limit — a consequence of adding type annotations and interface names to already-long lines. Budnix worked through these across four passes covering ghostTable, notification, pagination, dragToScroll, walkontable/core/clone.ts, valueSetter.ts, and deepMerge. NumericCellMeta was extracted into its own named type as part of this pass — a side effect of fixing a line-length violation that also improved readability.
Budnix also resolved two JSDOC errors — in mergeCells and focusOrder — that were blocking the typedoc generation pipeline.
Continuous Sync: Absorbing the 17.1.0 Release
While the polish work was in progress, develop shipped version 17.1.0. The TypeScript branch absorbed the full release: merge conflict resolution (including a shift key added to ignoreScrollSources in core.ts for DEV-1703, and a JSDoc comment removal in the Angular wrapper), pnpm lock file regeneration, Playwright bumped to ~1.60.0, and the create-github-app-token GitHub Actions dependency updated to v3.2.0. The CI pipeline was also hardened: Budnix made the stable-prepare CHANGELOG step idempotent (safe to re-run) and fixed a crash in setVersion when the version was already set.
The CellValue type change (any → unknown) also required a documentation update: we updated the cell-validator guide examples to add typeof string guards before string operations on CellChange[3] values, since those values are now unknown rather than any. This is a direct example of the migration making existing documentation more correct — the examples now demonstrate the safe pattern that TypeScript requires.
All Tests Green
Furthermore, the migration is complete and all tests pass:
- Unit tests: 2,701 / 2,701 passed (Jest)
- E2E tests: All themes (main, classic, horizon), regular and minified bundles
- Walkontable tests: Pass (separate test runner)
- React wrapper: Pass
- Angular wrapper: Pass
- Vue 3 wrapper: Pass
- Visual regression tests: Cross-browser and multi-framework
The team never modified a single E2E test file during the migration. For a codebase this old, with this much customer code depending on its exact behavior, that single number is the one we’re most proud of.
What This Means for Developers Using Handsontable
Better autocomplete and IDE support. Types generated from the actual implementation match what the library does at runtime. IDE completion for methods, options, and hook callbacks is now more accurate.
Catch misuse at compile time. Using a hook callback with the wrong signature, passing an invalid value to updateSettings(), or calling a method that doesn’t exist on an editor — TypeScript will catch these before you ship.
More reliable type declarations. The hand-authored drift is gone. When a method changes, the type changes with it automatically.
Breaking change: TypeScript peer dependency. If you use Handsontable’s type declarations in a TypeScript project, the type declaration format has changed. Previously: hand-authored .d.ts files. Now: compiler-generated from the source. Check the migration guide if you have custom type augmentation or module declaration merging targeting the old paths.
What Comes Next
Together, the migration and the polish phase are the foundation, not the finish line. The next phases:
Enable stricter TypeScript settings incrementally —
strictNullChecks,strictFunctionTypes,strictPropertyInitialization,noImplicitThis. Each one will be a separate PR targeting a specific category of safety improvement. The polish phase has already reduced the surface area significantly — the remaininganyusages are documented and justified, theas-cast density is at a fraction of its initial level, and the codebase is much closer to being clean under stricter settings than it was when the branch was first opened.Finish typing the Walkontable rendering engine — the polish phase migrated
viewport.jsto TypeScript and deeply typed the walkontable renderer, settings, and selection manager. The remaining JavaScript files in walkontable will follow in a subsequent effort.Tighten the remaining justified
anyusages — the ~10 remaininganyannotations are in load-bearing index signatures and variadic-args positions. As the plugin interface and hook handler types are formalized in follow-up PRs, we’ll replace these.Full
strict: true— the end goal. When every file is clean under full strictness, Handsontable’s TypeScript support will be on par with projects built natively in TypeScript from day one.
Lessons for Teams Considering a Similar Migration
Don’t try to convert everything at once. The POC proved this. One file at a time, type-checker green after each file, is the only approach that doesn’t generate an unmanageable error backlog.
AI tools are effective for the straightforward 80%. Renaming extensions, adding parameter types, annotating return types on simple functions — this is where AI tools shine and where they provide genuine velocity. The architectural decisions, the non-idiomatic patterns, the tricky prototype chains — those require human judgment.
“It compiles” is not the same as “it’s ready to merge.” The polish phase was not a small afterthought — it was a substantial engineering effort in its own right. Getting a large codebase to compile under noImplicitAny takes one kind of effort; getting it to a state where the type annotations are genuinely useful rather than escape hatches takes another. Plan for both.
Your tests are the behavioral contract. A type migration should not change runtime behavior. If a test breaks, the migration is wrong. Treat every test failure as a signal, not an obstacle.
Strategy and Economics
The build pipeline and the type system are coupled. You can’t add TypeScript support without also deciding how TypeScript gets compiled. Plan the build system migration and the language migration together, not sequentially.
Type drift is a real cost. Hand-authoring type declarations is not free maintenance — it’s a process that creates divergence every time it’s skipped. Auto-generation is not just convenient; it’s the only approach that stays correct at scale.
Security issues hide in typed code too. The prototype pollution guards discovered during the polish phase existed in the original JavaScript. TypeScript didn’t introduce them, but the structured review process that a migration forces revealed them. Code review triggered by a migration catches things that routine review misses.
If you’ve been postponing a migration like this, re-examine the math. The reasons we kept postponing this one were good reasons in 2019, in 2021, even in 2023. They aren’t the same reasons today. AI coding tools don’t make hard architectural decisions for you, but they do make the long mechanical tail of a migration tractable in a way it wasn’t before. If your team has a long-deferred ambition that’s been blocked by sheer scale, it may be worth looking at it again.
A Quiet Milestone
For our team, this isn’t just a technical changelog entry. It’s the resolution of one of the longest-running internal threads in the company’s engineering history — a project the team had suggested, scoped, deferred, re-scoped, and deferred again across multiple roadmap cycles, multiple team compositions, and multiple TypeScript major versions.
Two People, One Migration
It took two people to actually ship it. Mateusz Wojczal did the initial conversion — months of work getting 342,000 lines of JavaScript to compile cleanly as TypeScript, absorbing 50 develop commits along the way, holding the ADRs in his head and making the architectural calls that no automated tool could make. Then he handed the branch to Krzysztof “Budzio” Budnik, who brought the senior engineering judgment needed to take it from “compiles” to “mergeable”: eliminating hundreds of escape hatches, hardening cross-realm safety, fixing security vulnerabilities the migration surfaced, absorbing the 17.1.0 release, and leaving the codebase cleaner than he found it.
The reason it kept getting deferred wasn’t fear or lack of will. It was honest engineering judgment: at every previous attempt, the cost was real and the leverage wasn’t there yet. What changed is that the leverage finally caught up to the cost. The codebase didn’t get smaller. We got better tools, a clearer plan, and the discipline to apply both — one file, one commit, one review at a time — until the work was done.
Customers running Handsontable in production will notice nothing different — the runtime behavior is unchanged. For developers building on top of it: the types in your IDE are now generated from the same code that runs at runtime, and they will stay that way. To everyone on our team who carried this idea through years of “next quarter”: it shipped.
Handsontable is now a TypeScript data grid. The TypeScript migration is tracked under PR #12011. Feedback and contributions welcome.



