Introduction to Advanced Work with WebAssembly and TypeScript
\n\nWebAssembly (WASM) has long ceased to be an experimental technology. Today it is a powerful tool for solving performance-critical tasks: image processing, cryptography, simulations, compilers, and game engines. However, simply loading a WASM module in the browser is just the tip of the iceberg. At an advanced level, developers face the need for efficient memory management, passing complex data structures, and deep integration with TypeScript.
\n\nIn this article, we will break down key techniques that will allow you to reach a new level of mastery with the WebAssembly + TypeScript combination. You will learn how to optimize data transfer, work with linear memory, create high-performance abstractions, and debug WASM modules.
\n\n1. Linear Memory Management and Passing Complex Structures
\n\nOne of the main features of WebAssembly is working with linear memory. Unlike JavaScript, where you work with objects and garbage collection, in WASM you directly manipulate bytes. For advanced work, you need to be able to efficiently allocate, free, and transfer memory between the two environments.
\n\n1.1. Memory Allocation and Working with Pointers
\n\nWhen compiling C/C++ code via Emscripten or using AssemblyScript, you get the malloc and free functions. In TypeScript, we can call them directly for manual memory management.
// TypeScript\ninterface WasmExports {\n malloc: (size: number) => number;\n free: (ptr: number) => void;\n processData: (inputPtr: number, inputLen: number) => number;\n}\n\nconst wasmModule: WebAssembly.Instance = await loadWasm();\nconst exports = wasmModule.exports as unknown as WasmExports;\n\n// Create an array of numbers in WASM memory\nconst data = new Float32Array([1.5, 2.7, 3.14, 4.0]);\nconst bytesPerElement = Float32Array.BYTES_PER_ELEMENT;\nconst totalBytes = data.length * bytesPerElement;\n\n// Allocate memory and copy data\nconst ptr = exports.malloc(totalBytes);\nconst wasmMemory = new Float32Array(\n (wasmModule.exports.memory as WebAssembly.Memory).buffer,\n ptr,\n data.length\n);\nwasmMemory.set(data);\n\n// Call the WASM function\nconst resultPtr = exports.processData(ptr, data.length);\n\n// Read the result\nconst result = new Float32Array(\n (wasmModule.exports.memory as WebAssembly.Memory).buffer,\n resultPtr,\n 1\n);\nconsole.log('Result:', result[0]);\n\n// Free memory\nexports.free(ptr);\nexports.free(resultPtr);\n\n1.2. Passing Strings and Objects
\n\nStrings in WASM are simply sequences of bytes. To pass them from TypeScript, you need to encode the string in UTF-8 and pass a pointer to the start of the data along with the length.
\n\n// Helper function for encoding a string\nfunction stringToWasm(str: string, exports: WasmExports): { ptr: number; len: number } {\n const encoder = new TextEncoder();\n const encoded = encoder.encode(str);\n const ptr = exports.malloc(encoded.length);\n const wasmBuffer = new Uint8Array(\n (exports.memory as WebAssembly.Memory).buffer,\n ptr,\n encoded.length\n );\n wasmBuffer.set(encoded);\n return { ptr, len: encoded.length };\n}\n\n// Usage\nconst { ptr, len } = stringToWasm("Hello, WASM!", exports);\nconst resultPtr = exports.processString(ptr, len);\nconst result = readCString(resultPtr, exports); // string reading function\nexports.free(ptr);\nexports.free(resultPtr);\n\n2. Performance Optimization: Minimizing Crossings
\n\nEach call from TypeScript to WASM and back has overhead. For high-performance applications (e.g., a physics engine or real-time video processing), the number of such crossings should be minimal.
\n\n2.1. Batch Data Processing
\n\nInstead of calling a WASM function for each element of an array, it is better to pass the entire array at once and process it inside the module. This reduces the number of crossings and improves cache locality.