Figma's journey to TypeScript — compiling away our custom programming language¶
Summary¶
Figma migrated the entire Skew codebase underlying its prototype viewer
and mobile client to TypeScript via a custom Skew-to-TypeScript
transpiler (not a conventional rewrite), rolled out gradually by (1)
keeping the original Skew→JavaScript pipeline live, (2) checking generated
TypeScript into the same repo for developer visibility, (3) cutting the
production JS bundle to be built from the TypeScript instead of Skew, then
(4) deleting Skew and making TypeScript the source of truth. Three
language-semantic differences (JavaScript array-destructuring perf,
Skew-only devirtualization optimization, TypeScript's strict top-level
initialization ordering) and a build-system-semantic difference (Skew's
if BUILD == "TEST" conditional compilation has no TypeScript analogue)
drove targeted transpiler work. Source maps had to be composed across
two compilation steps so browser debuggers could still map breakpoints
set in Skew or TypeScript back through the final JS bundle. Enabled by
three pre-conditions: WebAssembly's mobile browser support finally
arriving (~2018–2020), Figma replacing hot Skew paths with its C++ engine
(softening the perf penalty of switching), and team growth affording the
migration investment.
Key takeaways¶
-
"Compile to the industry standard" beats "keep optimizing our custom language" — Skew had real perf advantages in 2016 (static types allowed compiler optimizations, 2020 TS benchmarks were ~2× slower in Safari). The advantages eroded once (a) mobile WASM let the C++ engine handle hot paths directly, and (b) the cost of ecosystem isolation (onboarding, linters/bundlers/analyzers, integration with the rest of Figma's codebase) compounded faster than the compiler-optimization advantage. Timing matters — "technologies are constantly improving and we learned to never doubt the rate at which they mature."
-
A transpiler turns a one-way-door rewrite into a gradual migration. Rather than rewriting file-by-file (would block feature development), Figma built a Skew→TS transpiler, checked both Skew sources and generated TS into Git simultaneously, and flipped source-of-truth once the TS bundle passed the test suite and handled production traffic. Canonical patterns/gradual-transpiler-migration. The cutover was scheduled for Friday night with ample notice; the Skew code was then deleted (concepts/simplicity-vs-velocity — no two-way door left).
-
concepts/source-map-composition is the hidden tax on a two-stage compile. A compiler backend normally emits one source map (source → generated). A transpiler in a pipeline (Skew → TS → JS via esbuild) needs two maps composed into one end-to-end map. Step 1: TS→JS map from esbuild (free). Step 2: per-file Skew→TS map from the transpiler. Step 3: for each entry in the TS→JS map, look up its TS location in the corresponding Skew→TS map and emit a direct Skew→JS entry. Without this, developer-visible breakpoints in Skew (and later TS) would be silently wrong — a monitoring-paradox-style invisible regression in developer tooling. Broader principle: any gradual source-language migration that routes through a transpiler must own the source-map-composition path as a first-class part of the migration.
-
Transpilation exposes load-bearing language-semantic differences that textbook comparisons miss. Three Figma-specific ones:
- Array destructuring is slow in JS runtimes:
const [a, b] = fn()constructs an iterator, not direct index access. Figma was using it to retrieve fromargumentsand took up to 25% per-frame latency hit. Fix: transpile to direct index access. - Skew does devirtualization
(
x.m(a, b) → m(x, a, b)) as a compile-time optimization; TypeScript does not. This subtly changes null-access semantics — a nullxthrows on the method call, but an uncalled devirtualizedm(null, a, b)might not. Caused a real Smart Animate breakage in production. Remediation: log every would-be-devirtualized call site for a period, fix the ones that fire with null. -
Initialization ordering is significant in TypeScript, not Skew. Skew makes every symbol available at load time; TypeScript only initializes a namespace/class after its defining import runs, so forward references to class statics at module top level are compile errors. Initial transpiler output flattened everything to global scope (correct but unreadable); later rework respected TS ordering and re-introduced namespaces for clarity.
-
Language-level conditional compilation has no TypeScript equivalent — work with the bundler instead. Skew's top-level
if BUILD == "TEST"lets the compiler specialize classes per build target (including defining test-only methods). TypeScript can't conditionally define methods at type-check time. Figma's workaround: define the method on every build with theBUILD == "TEST"check inside, rely on esbuild'sdefines+ dead code elimination to strip the non-matching branches. Cost: final bundle is slightly larger because test-only function names are present in every build (bodies are stripped; unexported top-level symbols are tree-shaken). Measured acceptable. Structural observation: when a feature of the old language lives in its compiler, migrating languages means relocating that feature into the new build step. -
Gradual rollout of the generated output isn't optional at this scale. Figma kept the old (Skew→JS) and new (Skew→TS→JS) bundle pipelines running simultaneously, gated the production cutover behind a feature flag, and caught a Smart Animate breakage internally — let them turn off the rollout, fix the root cause (devirtualization divergence), and proceed. Same shape as patterns/shadow-migration: run both, compare, reconcile, cut over only when confidence is earned. Differs from the Amazon BDT instantiation in what's being compared — here it's generated-code behavioural equivalence verified by tests + a user-facing feature, not dataset-level statistical equivalence.
-
Pre-conditions, not just benefits, gate language migrations. Figma names three that had to land first: (1) WebAssembly mobile browser support (2018 widespread, 2020 performant by their benchmarks) — without which the C++ engine couldn't run on iOS Safari; (2) Skew→C++ component replacements on hot paths (file loading etc.) — softened the perf penalty of switching away from Skew's optimizing compiler; (3) team scale — the 2020 Maker Week prototype showed migration was possible, but only later org growth afforded the headcount. Fresh rewrites that ignore the pre-conditions tend to fail with the pre-conditions still unmet.
Architectural numbers¶
- Final bundle: slightly larger (test-only function names present in every build; bodies stripped; unexported top-level symbols tree-shaken).
- Array-destructuring removal on hot path: up to 25% per-frame latency improvement.
- 2020 benchmark (pre-migration): TypeScript vs Skew ≈ ~2× slower on prototype loading in Safari (at the time the only iOS browser engine). That number was the blocker.
- Post-migration benchmarks matched Skew's perf (after the three targeted transpiler fixes).
Caveats / limitations¶
- No post-migration fleet-wide perf number beyond "matches Skew perf" — this is a self-reported internal engineering retrospective, not an independent benchmark.
- No disclosed line count of Skew code migrated, no transpiler code size, no timeline for the transpiler project beyond "first prototyped in 2020 at Maker Week → finished recently as of 2024-05".
- Ecosystem-specific lessons: many "we moved off an in-house language to TypeScript" decisions won't generalize to a different source language, target language, or web/mobile constraint mix. The source-map-composition, gradual-transpiler, and build-step-conditional- compilation mechanics do generalize.
- Evan Wallace (former Figma CTO, Skew's author) went on to write esbuild informed partly by this experience — a mild second-order credibility signal.
Links¶
- Raw file:
raw/figma/2024-05-03-figmas-journey-to-typescript-b016236f.md - Original URL: https://www.figma.com/blog/figmas-journey-to-typescript-compiling-away-our-custom-programming-language/
- HN discussion: https://news.ycombinator.com/item?id=40245686 (261 points)