BattlefyBlogHistoryOpen menu
Close menuHistory

Typescript in WebAssembly as Zig struct

Ronald Chen November 14th 2022

Typescript allows us to code with types, but WebAssembly only supports primitive types.

const { exports: { sendPerson, .. } } = await WebAssembly.instantiate(..);

type Person = {
  name: string;
  gpa: number;
};

const bob: Person = {
  name: 'Bob',
  gpa: 3.6
};

// sendPerson(person: Person)
sendPerson(bob); // this won't work 🙁

Doubly frustrating, a WebAssembly supported language like Zig can easily represent the same type.

const Person = extern struct {
  name: [*:0] const u8,
  gpa: f32,
};

Are we screwed? Do we have to pass each field as a function parameter? That would work, but we can do better.

The trick is remembering, in a system language like Zig, a struct is simply a memory layout. All we need todo is write the TypeScript object into WebAssembly memory, and Zig will be able to read it natively.

const { exports: { sendPerson, .. } } = await WebAssembly.instantiate(..);

type Person = {
  name: string,
  gpa: number
};

const bob: Person = {
  name: 'Bob',
  gpa: 3.6
};

// sendPerson(pointer: number)
sendPerson(encodePerson(bob)); // this will work 😀

If we line up all our ducks in a row on the Zig side, we can interpret the pointer directly as a pointer to a Person!

export fn sendPerson(person: *Person) void {
  ..
}

Holup, didn't we say WebAssembly only supports primitive types? How is it possible to receive a Person pointer? Recall pointers are just memory addresses. The WebAssembly memory address space is 32-bit; thus, pointers are i32. Therefore *Person is the same as i32!

Encoding Person

What black magic is this encodePerson? How does it take in a TypeScript person and produce a WebAssembly memory address?

We need to allocate memory and then write the values in the layout Zig expects. Most of this was already covered in a previous post when we showed how to send strings from Javascript to WebAssembly.

The memory layout is specified by the C ABI. Here is how Bob, with a GPA of 3.6, would be laid out.

The address of the person 0x0000AA00 is returned by the allocator. Same for the address of name 0x0000BB00.

Once we understand the memory layout, encodePerson is straightforward.

const encodePerson = ({ name, gpa }: Person) => {
  const sizeOfPerson = sizeOfUint32 + sizeOfFloat32;
  const personPointer = allocUint8(sizeOfPerson);
   // personPointer == 0x0000AA00

  const namePointer = encodeNullTerminatedString(name);
  // namePointer == 0x0000BB00

  const namePointerSlice = new Uint32Array(
    memory.buffer,
    personPointer,
    1
  );
  namePointerSlice[0] = namePointer;

  const gpaPointer = personPointer + sizeOfUint32;
  // gpaPointer == 0x0000AA04

  const gpaSlice = new Float32Array(
    memory.buffer,
    gpaPointer,
    1
  );
  gpaSlice[0] = gpa;

  return personPointer;
};

Since encodePerson allocated memory, we need to clean this up on the Zig side.

export fn sendPerson(person: *Person) void {
  defer allocator.destroy(person);
  defer allocator.free(std.mem.span(person.name));

  ...use person
}

Aside: Zig defer can be thought of as Javascript try..finally. Just note that defers are executed last in, first out.

fn foo() {
  defer alpha();
  defer beta();
  bar();
}

// can be thought of as

function foo() {
  try {
    bar();
  } finally {
    beta();
    alpha();
  }
}

Sending a Person from Zig to Typescript is the same process but reverse. Allocate memory for Person in Zig, pass over the pointer, decode Person out of memory in Typescript, and deallocate memory.

See full code sample for passing Person in either direction.

Do you want to learn the latest web tech while building esports? You're in luck, Battlefy is hiring.

2022

Powered by
BATTLEFY