Why Figma left its homegrown language behind

Core parts of Figma’s mobile rendering architecture have long been written in Skew, a custom programming language created at Figma to maximize playback engine performance. Skew began as a side project in the company’s early days, when a prototype viewer needed to work on both web and mobile. What started as a quick way to spin that up evolved into a full compile-to-JavaScript language with advanced optimization and fast compile times.

But as Skew code accumulated in the prototype viewer, its disadvantages became harder to ignore. New hires struggled to ramp up, the language couldn’t easily integrate with the rest of the codebase, and it lacked any developer ecosystem beyond Figma. The cost of scaling it eventually outweighed the original benefits. Figma has since migrated all Skew code to TypeScript.

Moving to TypeScript brings several practical improvements:

  • Static imports and native package management streamline integration with internal and external code
  • A large developer community provides tools like linters, bundlers, and static analyzers
  • Modern JavaScript features such as async/await and a more flexible type system are available
  • Onboarding is easier for new developers and other teams face less friction

Three developments made the migration possible:

  • More mobile browsers added WebAssembly support
  • Many core Skew engine components were replaced with equivalents from Figma’s C++ engine, reducing the performance penalty of moving to TypeScript
  • Team growth freed resources to focus on developer experience

Mobile WebAssembly support arrived

When Figma’s mobile codebase was first built, mobile browsers had no WebAssembly support and couldn’t load large bundles performantly. The main C++ engine code—which would need to compile to WebAssembly—wasn’t an option. TypeScript was also in its infancy, making it less obvious than Skew, which had static types and a stricter type system that enabled advanced compiler optimizations. WebAssembly gained widespread mobile support by 2018, and Figma’s testing showed reliable mobile performance by 2020.

Other optimizations caught up

Skew offered classic compiler optimizations like constant folding and devirtualization, plus web-specific ones such as generating JavaScript with real integer operations. In 2020, benchmarks suggested that loading Figma prototypes in TypeScript would be nearly twice as slow in Safari, which was the only browser engine allowed on iOS. (Apple opened iOS to other browser engines for EU users in iOS 17.4; WebKit remains the only engine elsewhere.)

Years after WebAssembly reached mobile, many of the hottest Skew engine code paths—notably file loading—were replaced with C++ engine components. That reduced the performance loss from moving to TypeScript and gave the team confidence to give up Skew’s optimizing compiler.

Team growth provided resources

In Figma’s early years, an automated migration wasn’t justifiable while a small team was building as fast as possible. Larger prototyping and mobile teams made the effort affordable.

The three-phase conversion

A 2020 prototype migration showed near-doubled slowdowns with TypeScript. After WebAssembly support proved sufficient and the mobile engine’s core moved to C++, the team revived the old prototype during a company Maker Week, demonstrating a working migration that passed all tests. Despite thousands of developer experience issues and non-fatal type errors, they had a rough plan.

The goal was to convert the whole codebase to TypeScript without pausing development or introducing runtime errors and performance regressions. Manual rewriting wasn’t feasible, but automation also had to handle real semantic differences. TypeScript only initializes namespaces and classes after a file is imported, so unexpected import order can trigger runtime errors. Skew made every symbol available at runtime on load, avoiding that problem.

The team built a Skew-to-TypeScript transpiler, building on work started by former CTO Evan Wallace, and rolled out a new TypeScript-generated bundle gradually.

Phase 1: Write Skew, build Skew

The original build process remained intact while the transpiler was developed. Generated TypeScript code was checked into GitHub so developers could see what the new codebase would look like.

Developing a Typescript transpiler, alongside our original Skew pipeline
Developing a Typescript transpiler, alongside our original Skew pipeline

Phase 2: Write Skew, build TypeScript

Once a TypeScript bundle passed all unit tests, production traffic began building from the TypeScript codebase. Developers still wrote Skew; the transpiler converted their code and updated the TypeScript files in GitHub. Type errors in generated code were fixed as they appeared—TypeScript can still produce a valid bundle with type errors.

Rolling out production traffic to the TypeScript codebase that our TypeScript compiler generated from Skew source code
Rolling out production traffic to the TypeScript codebase that our TypeScript compiler generated from Skew source code

Phase 3: Write TypeScript, build TypeScript

After everyone had gone through the TypeScript build process, the TypeScript code became the source of truth. The team identified a quiet period with no merges, shut off auto-generation, and deleted the Skew code from the codebase.

Making the cutover to use the Typescript codebase as the source of truth for developers
Making the cutover to use the Typescript codebase as the source of truth for developers

The gradual approach had clear benefits. Full control over the Skew compiler made Phase 1 easier; compiler parts could be added and modified freely to meet needs. The rollout also surfaced issues early—an internal breakage with Smart Animate was caught during the TypeScript rollout, and gated release allowed the team to disable it, fix the problem, and adjust the plan.

The cutover itself was carefully timed: on a Friday night, the team merged all necessary changes to remove auto-generation and make continuous integration run off TypeScript files directly.

Building a transpiler that produces readable TypeScript

Compilers traditionally split into a frontend and a backend. The frontend parses source code, performs type and syntax checking, and emits an intermediate representation (IR) that captures the program's semantics. The backend converts that IR into a target language—for Skew, the backend generated mangled, minified JavaScript.

A transpiler is a compiler whose backend emits human-readable code. For our migration, we needed a transpiler that turned Skew's IR into readable TypeScript. Early work went smoothly—we borrowed heavily from the existing JavaScript backend. But three problems surfaced near the end: array destructuring performance, devirtualization semantics, and initialization order.

Array destructuring was a performance trap

Prototype comparisons showed lower frame rates in TypeScript than Skew. The root cause was JavaScript's array destructuring, which constructs an iterator rather than indexing directly. Operations like const [a, b] = function_that_returns_an_array() were slower than necessary. We were using destructuring to pull values from JavaScript's arguments object. Replacing it with direct index access improved per-frame latency by up to 25%.

Devirtualization changed method call behavior

Skew's compiler performs devirtualization: under certain conditions, it hoists a method out of its class into a global function as an optimization. TypeScript doesn't do this, and the difference caused a breakage in Smart Animate. When myObject was null, the devirtualized call executed without error while the TypeScript call threw a null access exception. The divergence raised concerns about other call sites.

We added logging to every function eligible for devirtualization, ran it in production for a short period, analyzed the logs, and fixed all problematic call sites. That audit gave us confidence the TypeScript code was robust.

Initialization order differs between languages

Skew doesn't care about declaration order for variables, classes, namespaces, or functions. TypeScript does—referencing a static class variable before the class definition is a compile-time error. Our first transpiler version avoided the issue by flattening everything into the global scope, but the output was unreadable. We reworked the transpiler to emit declarations in dependency order and brought back TypeScript namespaces for readability.

The final transpiler passed all unit tests and compiled to TypeScript that matched Skew's performance. For a few small issues, we patched the Skew source manually rather than extending the transpiler—automating every fix wasn't worth the engineering cost.

Source maps kept debugging seamless

Browser debuggers operate on JavaScript, but our developers set breakpoints in Skew or TypeScript. Source maps bridge that gap by linking code locations in compiled JavaScript back to the original source. For a Skew file, the compiler generated JavaScript and a source map; the browser reversed the map to translate a breakpoint set in Skew into a JavaScript location.

Phase 2 of the migration introduced a new pipeline: TypeScript emitted by the transpiler, then bundled with esbuild. The old Skew-to-JavaScript maps no longer applied. We needed three pieces of new infrastructure:

  1. esbuild generated a TypeScript-to-JavaScript map (ts-to-js.map) during bundling.
  2. The transpiler emitted a Skew-to-TypeScript map for each .sk file, named file.map, emulating the Skew compiler's own source map logic.
  3. A build step composed the two maps: for each entry in ts-to-js.map, it located the source TypeScript file, looked up that position in the corresponding file.map, and combined the JavaScript location with the resulting Skew location.

The composed map gave us Skew-to-JavaScript mappings for the new bundle, and the developer debugging experience was unchanged.

Conditional compilation moved into the bundler

Skew supported top-level if statements whose conditions were compile-time constants passed via a "defines" option. That allowed different build variants for the same codebase—like separate debug and test bundles—with functions and classes swapped per target.

if BUILD == "TEST" {
  class HTTPRequest {
    def send(body string) HTTPResponse {
      # test-only implementation...
    }

    def testOnlyFunction {
      console.log("hi!")
    }
  }
} else {
  class HTTPRequest {
    def send(body string) HTTPResponse {
      # real implementation...
    }
  }
}

Compiling with a BUILD: "TEST" definition produced the test-specific implementation:

function HTTPRequest() {}
HTTPRequest.prototype.send = function(body) {
  // test-only implementation...
}

HTTPRequest.prototype.testOnlyFunction = function(body) {
  console.log("hi!")
}

TypeScript has no conditional compilation, so we had to perform it after type-checking, during bundling, using esbuild's "defines" and dead code elimination. That meant conditions could no longer influence type-checking—code where a method exists only in one build mode wouldn't type-check in the others. We rewrote such patterns so all symbols exist in all modes:

// Value defined during esbuild step
declare const BUILD: string

class HTTPRequest {   
  send(body: string): HTTPResponse {
    if (BUILD == "TEST") {
      // test-only implementation...
    } else {
      // real implementation...
    }
  }
  
  testOnlyFunction() {
    if (BUILD == "TEST") {
      console.log("hi!")
    } else {
      throw new Error("Unexpected call to test-only function")
    }
  }
}

This TypeScript compiled to the same JavaScript as the original Skew code:

function HTTPRequest() {}
HTTPRequest.prototype.send = function(body) {
  // test-only implementation...
}
HTTPRequest.prototype.testOnlyFunction = function(body) {
  console.log("hi!")
}

The trade-off was a slightly larger bundle since symbols that once existed only in selected build modes were now always present. That size increase was acceptable, and tree-shaking still removed unexported top-level symbols.

TypeScript unlocks the codebase's future

Migrating all Skew code to TypeScript modernized a critical Figma codebase, integrating it much more easily with internal and external code while improving developer efficiency. Skew was the right choice when the codebase was first written, but the ecosystem has since matured. TypeScript now makes sense where it didn't before.

Future work focuses on deeper integration with the rest of the codebase, simpler package management, and directly leveraging new features from the TypeScript ecosystem. Our experience with import resolution, module systems, and JavaScript code generation will feed into those efforts.