Figma's Journey to TypeScript: Compiling Away a Custom Language
How Figma built a Skew-to-TypeScript transpiler and ran a three-phase migration to retire their custom language without disrupting developer workflows.

Figma spent years building a production codebase in a custom programming language that almost nobody outside the company knew. When the advantages that justified it slowly disappeared, they had to find a way out without stopping to rewrite.
Source
The evolution of Figma’s mobile engine: Compiling away our custom programming language
By: Brandon Lin, Figma
Published: May 01 2024
Type: Engineering article
Worth saving for the migration playbook — three clearly sequenced phases, each independently reversible, run against a live production codebase. The source-maps case study alone is reusable in any multi-step compile pipeline.
From Skew to TypeScript: retiring a custom language safely
Skew started as a side project in Figma’s early days. The goal was specific: ship the prototype viewer on both web and mobile. At a time when mobile browsers didn’t support WebAssembly and TypeScript was young, Skew offered static types, a strict type system, and compiler optimizations — constant folding, devirtualization, native-feeling integer arithmetic in generated JavaScript — that were difficult to replicate otherwise. So Skew stopped being a side project and became the language Figma’s entire mobile prototyping engine was built in.
By 2024, the three conditions that made Skew sensible had all flipped. WebAssembly reached reliable mobile performance around 2020. Figma replaced the hottest Skew code paths — file loading, core rendering — with equivalent C++ code that compiled to WebAssembly, eliminating the performance gap that had justified Skew’s optimizing compiler. And the prototyping and mobile teams had grown large enough that maintaining a language no one outside Figma had ever heard of was a real and growing cost. The tooling void was total: no linters, no bundlers, no static analyzers, no ecosystem.
The migration plan had one hard constraint: zero interruption to developer velocity. Rewriting by hand wasn’t viable — the codebase was too large. Instead, Figma built a Skew-to-TypeScript transpiler that extended work Evan Wallace had started years earlier. It took the intermediate representation their existing Skew compiler already produced and emitted human-readable TypeScript rather than minified JavaScript — that’s the distinction that makes something a transpiler: the output is meant for humans to work in, not machines to run directly.
The rollout ran in three phases. In Phase 1, developers still wrote Skew; the transpiler ran in the background and its TypeScript output was checked into GitHub as a visible preview — readable but not authoritative. In Phase 2, the TypeScript output became the production build target: developers still wrote Skew, the transpiler ran on every change, and production traffic shifted gradually to TypeScript bundles behind a feature flag. During this phase they caught a real breakage in Smart Animate internally — and the flag let them roll back in seconds, fix the issue, and continue. In Phase 3, on a Friday night after confirming no one was mid-merge, they deleted the Skew source and made TypeScript the source of truth.
Three technical problems stood between a functional transpiler and a correct one.
Array destructuring was the first. Benchmarking revealed lower frame rates in TypeScript. The root cause was how JavaScript handles const [a, b] = someFunction(): instead of indexing the returned array directly, it constructs an iterator. The transpiler had been generating destructuring to access the arguments object — exactly the wrong pattern for a hot path. Switching to direct array indexing cut per-frame latency by up to 25%.
Devirtualization was the second. Skew’s compiler, under certain conditions, hoists class methods to global functions: myObject.myFunc(a, b) becomes myFunc(myObject, a, b). TypeScript doesn’t. In devirtualized form, calling with a null myObject runs fine; in standard form, it throws immediately. That’s exactly what caused the Smart Animate breakage. The fix was to add logging to every affected call site, run briefly in production to surface any others, patch them, and only then proceed with the cutover.
Initialization order was the third. Skew makes every symbol available at runtime regardless of declaration order. TypeScript doesn’t — static class variables can’t be initialized before the class definition. The transpiler initially got around this by flattening everything into the global scope, which was safe but unreadable. The team reworked it to emit TypeScript in the correct dependency order and restore namespaces for clarity.
The source maps case study is the most transferable piece of the article. In Phase 2, the build pipeline became two stages: Skew → TypeScript via the transpiler, then TypeScript → JavaScript via esbuild. The old source maps linked Skew directly to JavaScript, which were now wrong. The solution was to generate two maps — Skew-to-TypeScript per source file, and TypeScript-to-JavaScript from esbuild — then compose them at build time. For each JavaScript location, look up the TypeScript position, look that up in the per-file Skew map, and write the composed entry. Developers could set breakpoints in Skew and the browser paused in the right place throughout all of Phase 2.
Conditional compilation was the final incompatibility. Skew’s top-level if BUILD == "TEST" construct excluded code from bundles entirely — a feature TypeScript has no equivalent for. The fix was to move the conditions inside class bodies and rely on esbuild’s defines feature plus dead-code elimination at the bundle step. The tradeoff was a slightly larger bundle — some test-only symbols now appear in all builds — but the gain was correct type-checking across all build modes, and the size increase was acceptable. Tree-shaking still removed unexported top-level symbols.
My take
The real lesson isn’t “build a transpiler.” It’s the sequencing: convert the build output before converting the source. Figma kept Skew as the source through all of Phase 2 while the compilation pipeline changed underneath. That’s what made every phase independently reversible — and what let the Smart Animate bug get caught before any developer had to write a line of TypeScript against a broken runtime.
There’s also a point about enabling conditions that transfers broadly. Figma didn’t migrate because TypeScript improved. They migrated because they’d already replaced the code paths where TypeScript would have been genuinely slower, so the performance argument for Skew was gone before the migration started. The same pattern applies to any migration: remove the technical reason to stay before you remove the old system.
Where this is useful
- When a team inherits a custom DSL or internal compile-to-JS framework and needs a safe exit strategy.
- When planning any multi-step codebase transformation where runtime behavior must stay correct throughout — the logical/physical split here mirrors Figma’s database sharding approach.
- When building a transpiler that runs in production: the source-maps composition technique transfers to any two-stage compile pipeline.
- Less applicable when the source language has semantic differences too deep to mechanically convert — Figma had full control over the Skew compiler, which is what made the transpiler tractable in the first place.
Apply this: If you’re planning a migration with a custom tool, build and validate the output first. Run both pipelines in parallel behind a flag. Only flip the source-of-truth when production traffic has proved the output correct.
What I want to remember
Remember: Migrate the build target before migrating the source. Figma kept Skew as the developer-facing source through all of Phase 2 — only the compilation pipeline changed underneath. That’s what made rollback instant and production bugs catchable before any developer wrote new TypeScript.