The problem with nullable types
Most popular typed languages—including TypeScript and Java—allow any variable to hold null, representing the absence of a value. This design choice dates back to 1965 and Tony Hoare, who later famously called it his "billion dollar mistake." Some languages, such as Rust, OCaml, Haskell, and C# 8.0, take a different approach: a value can be null only when explicitly declared nullable.
TypeScript offers an opt-in version of this stricter behavior through the strictNullChecks compiler flag. With the flag enabled, the compiler performs control flow analysis to prevent unsafe access on potentially null values—eliminating entire classes of runtime errors like cannot access .name of undefined.
// strictNullChecks: off
interface Vector { x: number, y: number }
var v: Vector = { x: 1, y: 2 } // ✅ This is allowed
v = null // ✅ This is also allowed
function length(v: Vector) {
// We may need to check for nullity when we're not sure.
if (v) {
return Math.sqrt(v.x * v.x + v.y * v.y)
} else {
// Return some default value?
// What does it even mean to call length(null)?
}
}
When we enabled strict null checks across Figma's frontend TypeScript codebase, several historical high-severity incidents turned out to be bugs this flag would have caught before reaching production. The flag also improves long-term maintainability: when the type system tells you whether "file info is loaded at this point," it becomes far easier to answer questions about null safety while reading code.
Three migration strategies
Figma adopted TypeScript before version 2.0, when strict null checks were introduced. Like many incrementally-typed codebases, ours didn't compile with the setting enabled from the start. When we launched the migration effort, our ~1162 TypeScript files produced over 4000 errors under strict null checks. We evaluated three ways to tackle it.
Stop-the-world migration
We could have paused all product work and asked every engineer to participate in the migration. That felt wrong on several fronts: the work is parallelizable but not that parallelizable, our product work at the time was critical for business momentum, and coordinating many teams grows impractical as a company scales.
Whack-a-mole fixing
Another option was fixing strict null check errors continuously (say, by running checks only during CI) without actually enabling the flag. This approach is minimally disruptive, but also fails to prevent new code from introducing fresh errors. Our codebase grew from 376k to 464k lines over the course of the migration; a backlog that occasionally grows backward makes for poor motivation.
Progressive allowlist
The strategy we chose was to add files one at a time to an allowlist. We drew inspiration from the VS Code team's similar migration, starting with their tooling and extending it for our own needs.
The core idea is compiling the codebase twice: once for all files without strict checks, and once for a list of files known to compile cleanly under the stricter rules.
// tsconfig.strictNullChecks.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strictNullChecks": true
},
"files": [
... add files here ...
],
}
Execution
A TypeScript file compiles together with all its dependencies, so a file can be strict-null-checked only if all its imports are already on the allowlist. This constraint drove our workflow:
- Treat the codebase as a dependency graph of files, with edges pointing from a file to the files it imports.
- List all candidate files whose dependencies are entirely on the allowlist. We wrote a script that sorts candidates by how many files depend on them, so we could gauge impact before committing to a migration.
- Pick a candidate, add it to the
filesfield intsconfig.strictNullChecks.json, then run the compiler and fix any new errors. - Once files land on the allowlist, previously blocked files become eligible.
- Repeat until the whole graph is clean, then enable
--strictNullChecksfor the entire codebase.
For planning and tracking, we built a visual dependency graph that made it straightforward to spot priority files and watch progress in real time.

Dealing with cycles
Our codebase had quirks that complicated static analysis. Barrel imports—which re-export multiple modules from a single entry point—caused trouble for the tooling. We used them only in a few places, so removing them was the simplest fix.
Dependency cycles were a bigger hurdle. A file inside a cycle can't be added to the allowlist unless all files in that cycle are added at once. We had to treat each cycle as a single strongly-connected component, collapsing it to one node in the graph.

The largest cycle spanned more than 500 files—nearly half the codebase at the time. Breaking that up would help not only the migration but also the overall architecture. Two common patterns caused most of the cycles:
- Redux models, actions, and reducers had historically been defined together in one file. Moving models into a separate file meant the action file no longer needed to import from the reducer file.
- UI modals were caught in a single cycle: the renderer in
modal.tsimported every modal, and each modal imported shared constants frommodal.ts. Extracting those constants into their own file untangled the cycle.
Taking apart those root causes cut the monolith down by hundreds of files, though the resulting changes were large and often conflicted with other commits. In one case, we automated a refactor with jscodeshift so the entire change could be re-generated and rebased onto the latest master without manual conflict resolution.
Refactor or annotate? A migration policy
Figma’s migration team did not mandate a uniform approach to every file. The central policy question was whether engineers should simply add nullable annotations where types were hard to prove, or go further and refactor code to eliminate dubious nullability. The team chose to encourage refactoring wherever feasible. The argument: annotations padded with | null often require a scattering of non-null assertions or redundant if-statements. Both patterns erode trust in the type system and undercut the point of enabling strict null checks in the first place.
Refactoring, though, is slower. It demands not only familiarity with the code but also with the product area to certify whether nullability is intentional—and it can introduce regressions. Figma accepted that risk. The company’s engineers were full-time employees available to fix any breakage, and the team reasoned that code which broke during migration was likely already fragile and prone to regress anyway.
The recommendation to refactor, however, was not a hard rule. The project’s goal was finishing the migration; lingering in a partially-migrated state carries its own costs, including running two compiler instances at once. So engineers were given wide discretion and could take shortcuts when doing otherwise would take too long. The balance struck here, the team notes, depends on context: an open-source project with volunteer contributors or a codebase full of legacy code might tilt the scale toward more conservative annotation-only changes.
Making the remaining work tractable
The execution logistics shifted as the project progressed. Initially, a small group of engineers scouted the landscape—setting up tooling and removing blockers such as circular dependencies. Once the groundwork was laid, the codebase was open for broader contribution. Figma ran two three-day sprints where a total of 30 engineers from various teams converted files, often focusing on areas they knew best. This spread the workload without disrupting team ownership.
For individual files, the effort required varied widely. Some errors were resolved by changing a single type annotation; others required refactoring to restructure how data flowed. A common example is using a shared mutable stack:
TypeScript
// stack: Array<number>
if (stack.length > 0) {
const v = stack.pop() // type: number | undefined
console.log(v + 1) // error: Object is possibly undefined
}
This snippet is correct by inspection but fails under strict null checks. The fix may be as blunt as adding a non-null assertion, or as careful as rewriting it entirely:
TypeScript
const v = stack.pop()
if (v != null) {
console.log(v + 1) // ✅
}
The choice between these two outcomes is precisely the policy decision Figma faced for every file in the codebase. Choosing the assertion keeps the migration fast but leaves the code vulnerable. Choosing the rewrite keeps the code sound but costs time and effort—and a full rewrite may expose whether a null value was intended in the first place.
Finishing the job
As the migration approached completion, the remaining work resisted parallel effort. At that checkpoint, Figma swapped its allowlist approach for a denylist: rather than enumerating compiled files with the files option, the team used the exclude option so that every new file would automatically compile under strict mode. A small set of engineers then converted the last files and finished the project.
The project was not a linear march to the finish line. There were quiet periods when both contributors and project drivers were pulled onto other work. The incremental structure paid off here—being able to make small, steady progress at the start built momentum while remaining flexible enough to pause when needed.
What strict null checks actually changed
On the measurable side, null-related errors have disappeared from Figma’s error dashboard. But the team says the more significant—and harder to quantify—benefit is in code readability. The parts of the codebase that gave TypeScript the most trouble in proving non-nullability were also the hardest for humans to reason about. Those regions, refactored during the migration, gained the most from the process. Over time, strict null checks pushed the codebase toward a state where type annotations are reliable indicators of actual behavior, not just scaffolding to satisfy the compiler.



