WebAssembly in TypeScript

WebAssembly in TypeScript

This article explains WebAssembly in TypeScript.

We will explain practical and easy-to-understand methods for integrating TypeScript and WebAssembly.

YouTube Video

WebAssembly in TypeScript

WebAssembly (Wasm) is a binary format runtime that runs at near-native speed within the browser. By calling Wasm from TypeScript, you can efficiently utilize compute-intensive processes and existing native libraries written in C/C++ or Rust.

Basic Execution Flow

Here, we will explain the basic execution flow of Wasm. TypeScript (or the browser) fetches the .wasm file, instantiates it, and calls exported functions.

    1. Create a .wasm binary using AssemblyScript, Rust, C++, or prepare an existing one.
    1. Fetch the .wasm file in TypeScript (or in the browser) and instantiate it synchronously or asynchronously.
    1. Call the exported functions and share memory using WebAssembly.Memory if necessary.

WebAssembly.instantiateStreaming

Next, we will show a basic example of loading a Wasm file and calling an exported function. The browser needs to support instantiateStreaming.

The following code is an example of fetching simple.wasm from the server and calling the add function.

 1// TypeScript: load-and-call.ts
 2// Fetch and instantiate a wasm module and call its exported `add` function.
 3async function run() {
 4  const response = await fetch('http://localhost:3000/simple.wasm');
 5  // Use instantiateStreaming when available for efficiency.
 6  const { instance } = await WebAssembly.instantiateStreaming(response, {});
 7  // @ts-ignore
 8  const result = instance.exports.add(2, 3);
 9  console.log('2 + 3 =', result);
10}
11run().catch(console.error);
  • Functions inside Wasm are stored in instance.exports.
  • Since TypeScript does not receive type information, you need to use @ts-ignore or create your own type definitions.

Workflow Using AssemblyScript

AssemblyScript allows you to write Wasm in syntax similar to TypeScript, making it an approachable choice for TypeScript developers. Here, we prepare a simple function, build it into .wasm and .d.ts, and call it from TypeScript.

1// assembly/index.ts (AssemblyScript)
2// npm install --save-dev assemblyscript
3
4// Export a simple function to add two integers.
5export function add(a: i32, b: i32): i32 {
6  return a + b;
7}
  • By using asc (the AssemblyScript compiler), you can generate a .wasm file and, optionally, a type definition .d.ts file. To try it locally, install assemblyscript with npm and build it.
1# build commands
2# npm install --save-dev assemblyscript
3npx asc assembly/index.ts   -o build/simple.wasm   -t build/simple.wat   --bindings esm   --exportTable   --sourceMap
4
5# optionally generate d.ts with --exportRuntime or use as-bind / loader tools

Here is an example of fetching and calling from the TypeScript side.

1// ts client that loads AssemblyScript-generated wasm
2async function runAssemblyScript() {
3  const res = await fetch('http://localhost:3000/build/simple.wasm');
4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
5  // @ts-ignore
6  console.log('AssemblyScript add:', instance.exports.add(10, 7));
7}
8runAssemblyScript().catch(console.error);
  • AssemblyScript requires careful handling of memory models and strings, but is very easy to use for basic numerical calculations.

Rust + wasm-bindgen (A powerful and commonly used option)

This section explains the workflow of writing Wasm in Rust and bridging it with JavaScript or TypeScript using wasm-bindgen. Here, we use a simple Fibonacci function as an example to demonstrate how to import the generated module as an ES module.

Export functions from the Rust side using wasm-bindgen.

 1// src/lib.rs (Rust)
 2// install wasm-pack from https://drager.github.io/wasm-pack/installer/
 3use wasm_bindgen::prelude::*;
 4
 5// Export a function to JavaScript using wasm-bindgen.
 6#[wasm_bindgen]
 7pub fn fib(n: u32) -> u32 {
 8    if n <= 1 { return n; }
 9    let mut a = 0;
10    let mut b = 1;
11    for _ in 2..=n {
12        let tmp = a + b;
13        a = b;
14        b = tmp;
15    }
16    b
17}
  • When you build with wasm-pack or the wasm-bindgen CLI, type definitions for TypeScript and JS wrappers are generated, allowing you to directly import them as ESM.
1# build with wasm-pack
2# install wasm-pack from https://drager.github.io/wasm-pack/installer/
3wasm-pack build --target nodejs --out-dir pkg

On the TypeScript side, import and use the ES module from pkg.

 1// Node.js: import WASM module built with --target web
 2// import init, { fib } from '../pkg/my_wasm_module.js';
 3// Node.js: import WASM module built with --target nodejs
 4import wasm from '../pkg/my_wasm_module.js';
 5
 6async function run() {
 7  //await init();   // --target web
 8  console.log('fib(10)=', wasm.fib(10));
 9}
10
11run().catch(console.error);
  • wasm-pack generates JavaScript wrappers and .d.ts type definitions, making it easy to use from TypeScript. Please note that when you specify web for the --target option of the wasm-pack command, asynchronous initialization is required.

Real-world Example of Memory Sharing: Passing and Processing Arrays (Low Level)

When exchanging large amounts of data with Wasm, sharing ArrayBuffer for efficient data exchange is important. Here we show an example using AssemblyScript, but the same principle applies with Rust's wasm-bindgen.

On the AssemblyScript side, prepare an exported function to write to memory. For example, a function to square each element of an array would look like this.

 1// assembly/array_ops.ts (AssemblyScript)
 2// Square values in place in the wasm linear memory starting at `ptr` for `len` elements.
 3export function square_in_place(ptr: usize, len: i32): void {
 4  // Treat memory as a pointer to 32-bit integers.
 5  for (let i = 0; i < len; i++) {
 6    let offset = ptr + (i << 2); // i * 4 bytes
 7    let value = load<i32>(offset);
 8    store<i32>(offset, value * value);
 9  }
10}

To specify the memory settings used by AssemblyScript, prepare the following asconfig.json.

1{
2  "options": {
3    "memoryBase": 0,
4    "importMemory": false,
5    "initialMemory": 1,
6    "maximumMemory": 10
7  }
8}
1 npx asc assembly/array_ops.ts   -o build/array_ops.wasm   -t build/array_ops.wat   --bindings esm   --exportTable   --sourceMap
  • To call this function, you need to copy the ArrayBuffer into the Wasm memory space and pass the pointer.

Below is an example of using WebAssembly.Memory in TypeScript to copy data and call the function.

 1// TypeScript: use memory to pass array to wasm
 2async function runArrayOps() {
 3  const res = await fetch('http://localhost:3000/build/array_ops.wasm');
 4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
 5  // @ts-ignore
 6  const memory: WebAssembly.Memory = instance.exports.memory;
 7  // Create a view into wasm memory.
 8  const i32View = new Int32Array(memory.buffer);
 9
10  // Example data
11  const input = new Int32Array([1, 2, 3, 4]);
12  // Choose an offset (in i32 elements) to copy data to (simple example: at index 0).
13  const offset = 0;
14  i32View.set(input, offset);
15
16  // Call wasm function: ptr in bytes, len in elements
17  // @ts-ignore
18  instance.exports.square_in_place(offset * 4, input.length);
19
20  // Read back result
21  const result = i32View.slice(offset, offset + input.length);
22  console.log('squared:', result);
23}
24runArrayOps().catch(console.error);
  • memory.buffer is the shared linear memory; minimizing copies improves processing speed as much as possible. Note also that pointers refer to positions in bytes, while TypedArrays are managed by element counts, so be careful not to mix up these differences.

Type-safe Handling: Prepare TypeScript Type Definitions

Wasm exports are JavaScript objects, so providing type definitions on the TypeScript side will make development easier. Here is a simple example of a type definition file.

The following shows the minimal type definition you can create manually as simple.d.ts.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • Placing this in the typeRoots of your tsconfig.json or using declare module will enable type checking. wasm-pack conveniently generates .d.ts files automatically, so it's useful to use them.

Initialization Patterns at Runtime: Synchronous vs Asynchronous

Since Wasm modules require I/O (fetching) and compilation, asynchronous initialization is common. However, there is also a pattern where you cache WebAssembly.Module beforehand and instantiate it synchronously.

Below is the basic code structure for initializing WebAssembly asynchronously. In actual projects, this pattern is recommended.

1// async init pattern
2async function initWasm(url: string) {
3  const res = await fetch(url);
4  const { instance, module } = await WebAssembly.instantiateStreaming(res, {});
5  return instance;
6}
  • Asynchronous initialization makes it easy to flexibly incorporate error handling and lazy loading, making it the most convenient in practical development. Furthermore, the code generated by wasm-pack includes an init() API for initialization, so getting used to this flow will help your work go smoothly.

Practical Performance Considerations

Here are some points to keep in mind for significant performance improvements. Please refer to these optimization tips when combining TypeScript and WebAssembly.

  • When function calls are very frequent, the overhead of calling between JavaScript and Wasm can become a bottleneck. We recommend batching data and processing it in one go as much as possible.
  • Memory allocation and copying increase processing load. Utilize shared buffers and pointers to minimize these operations.
  • Be careful when handling floating-point numbers. In TypeScript, they become the number type, but you can handle them accurately by matching the types on the Wasm side.

Summary

By combining TypeScript and WebAssembly, you can achieve near-native performance in the browser. This is particularly effective for computation-intensive tasks or when you want to leverage existing native assets. This combination is a very powerful option when you want to improve the performance of your web application.

You can follow along with the above article using Visual Studio Code on our YouTube channel. Please also check out the YouTube channel.

YouTube Video