Writing GPU Shaders in TypeScript With TypeGPU
Build GPU shaders in TypeScript with TypeGPU. Set up typed buffers, run a compute shader, and catch buffer and bind group mismatches at compile time.
TypeGPU is a TypeScript library that wraps WebGPU in typed primitives, so the TypeScript compiler checks your buffer schemas, bind group layouts and GPU functions, instead of those mistakes failing at runtime on the GPU.
If you’ve worked through a WebGPU tutorial, you know how it goes. The JavaScript side type-checks fine, then the WGSL string has a struct field one float off, and all you get back is a buffer of garbage with no error. This guide assumes you already know what WebGPU is. If you don’t, start with adding WebGPU effects to a UI and how WebGPU differs from WebGL. Here you’ll set up TypeGPU, build typed buffers, run one compute shader from start to finish, and see the type error that raw WebGPU can’t give you.
Key Takeaways
- To write a TypeGPU shader as a TypeScript function, you mark it with
'use gpu'and add theunplugin-typegpubuild plugin, which is otherwise an optional part of the library. - Every TypeGPU buffer is created from a schema such as
d.arrayOf(d.u32, 16). The schema sets the buffer’s byte size, how JavaScript values are converted to binary, and which values.write()accepts. - In a WGSL array, each
vec3felement takes 16 bytes. TypeGPU pads plain arrays and objects for you, but copies aFloat32Arraybyte-for-byte, so you have to add the padding yourself. - If you pass
root.createBindGroupa buffer whose schema or usage doesn’t match the layout, TypeScript rejects it at compile time. - TypeGPU is pre-1.0 (the 0.12.x line), and minor releases have removed deprecated APIs.
Why WGSL and the JS-to-GPU Boundary Cause Most WebGPU Bugs
Most WebGPU bugs don’t come from shader logic. They happen at the boundary. WebGPU shaders are written in WGSL, a separate language that your TypeScript tooling treats as an opaque string. The byte layout of your buffers, the numeric binding indices, and the match between each bind group and what the shader declares are all kept in sync by hand. Get one wrong and you typically see silent wrong data rather than a compiler error.
TypeGPU puts both sides in TypeScript. One schema describes the data on the CPU and on the GPU, and shader functions are written as TypeScript too.
Setting Up TypeGPU With Vite
Install the runtime, then the build plugin and WebGPU type definitions as dev dependencies:
npm install typegpu
npm install --save-dev unplugin-typegpu @webgpu/types
Register the WebGPU types in tsconfig.json. If TypeScript doesn’t know WebGPU types such as GPUDevice, the troubleshooting section of the Getting Started guide points you to this setting.
{ "compilerOptions": { "types": ["@webgpu/types"] } }
unplugin-typegpu is optional for TypeGPU as a whole. You need it to write shaders as 'use gpu' TypeScript functions. It is built on unplugin, so besides Vite it ships versions for esbuild, Farm, Rolldown, Rollup, Rspack and webpack, plus a separate Babel plugin for React Native.
// vite.config.ts
import { defineConfig } from 'vite';
import typegpu from 'unplugin-typegpu/vite';
export default defineConfig({
plugins: [typegpu()],
});
At runtime, await tgpu.init() gives you a root object. Every buffer, bind group and pipeline below is created from it.
Typed Buffers and Schemas
A TypeGPU buffer always comes from a schema built with d.* constructors. As the TypeGPU buffers reference explains, TypeGPU uses the schema to work out how big the buffer must be, how to turn JavaScript values into bytes and back, and what types its .write() and .read() methods take.
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Point = d.struct({
position: d.vec3f,
weight: d.f32,
});
const points = root
.createBuffer(d.arrayOf(Point, 2), [
{ position: d.vec3f(0, 0, 0), weight: 1 },
{ position: d.vec3f(1, 2, 3), weight: 0.5 },
])
.$usage('storage');
points.write([
{ position: d.vec3f(4, 5, 6), weight: 2 },
{ position: d.vec3f(7, 8, 9), weight: 3 },
]);
console.log(await points.read());
.$usage('storage') sets the WebGPU usage flag and also records it in the buffer’s type. When a buffer can’t be mapped, .read() copies its contents through a temporary staging buffer, so you never write mapAsync yourself.
WGSL alignment rules put a vec3f array element at 16 bytes: 12 bytes of data and 4 bytes of padding. If you pass plain arrays or objects to .write(), TypeGPU inserts that padding for you. If you pass a Float32Array, .write() copies its bytes exactly as given, so the array has to include the padding already:
const dirs = root.createBuffer(d.arrayOf(d.vec3f, 2)).$usage('storage');
dirs.write([[1, 0, 0], [0, 1, 0]]); // padded for you
dirs.write(new Float32Array([1, 0, 0, 0, 0, 1, 0, 0])); // every 4th float is padding
One Compute Shader End to End
The complete TypeGPU program below doubles an array of eight integers on the GPU and reads the result back. A 'use gpu' function passed to root.createGuardedComputePipeline receives a thread index. Calling dispatchThreads(n) runs n logical threads, and the index takes each value from 0 to n − 1 exactly once.
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const N = 8;
const values = root
.createBuffer(d.arrayOf(d.u32, N), [1, 2, 3, 4, 5, 6, 7, 8])
.$usage('storage');
const layout = tgpu.bindGroupLayout({
values: { storage: d.arrayOf(d.u32, N), access: 'mutable' },
});
const bindGroup = root.createBindGroup(layout, { values });
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
double.with(bindGroup).dispatchThreads(N);
console.log(await values.read()); // [2, 4, 6, 8, 10, 12, 14, 16]
The layout declares what the shader expects, and the bind group supplies the actual buffer. You never compute a workgroup count and never write a staging buffer. If you don’t need to swap buffers, root.createMutable(schema) is a shorter fixed-resource alternative: the shader reads it through .$, and you read results back with .read().
Bind Group Layouts and Compile-Time Type Safety
Compile-time checking of bind groups is where TypeGPU departs most from raw WebGPU. A bind group layout describes the resources a shader expects, and a bind group provides the concrete buffers. In TypeGPU, a buffer with the wrong schema or usage flags fails type checking when you create the bind group. The bind groups guide documents this behavior.
Storage entries are readonly by default, which is why the layout above sets access: 'mutable'. Because the shader refers to layout.$ rather than to one specific buffer, a single pipeline can run against different data:
const more = root
.createBuffer(d.arrayOf(d.u32, N), [10, 20, 30, 40, 50, 60, 70, 80])
.$usage('storage');
const bindGroupB = root.createBindGroup(layout, { values: more });
double.with(bindGroupB).dispatchThreads(N); // same shader, new data
const floats = root.createBuffer(d.arrayOf(d.f32, N)).$usage('storage');
root.createBindGroup(layout, {
values: floats, // type error: d.f32 elements don't match the d.u32 layout entry
});
Bind groups are immutable. Create one per resource combination, and cache them if you have a small, fixed set. In raw WebGPU, the compiler has no idea what the WGSL declares, so binding a float buffer where the shader expects u32 compiles cleanly and quietly produces wrong numbers.
The Same Shader in WGSL vs TypeGPU
A TypeGPU shader and its hand-written WGSL equivalent are about the same size. The difference is the host code around them. The hand-written WGSL version of the doubling shader looks like this:
@group(0) @binding(0) var<storage, read_write> values: array<u32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
if (id.x >= arrayLength(&values)) {
return;
}
values[id.x] = values[id.x] * 2u;
}
And the TypeGPU version from the earlier example:
const double = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x];
});
Running the WGSL version with raw WebGPU means writing all of this host code by hand:
device.createShaderModulewith the WGSL source stringdevice.createComputePipeline- bind group entries with numeric
bindingindices that must match@binding(0) - a workgroup count calculation for
dispatchWorkgroups - a
MAP_READstaging buffer, a copy command, andmapAsyncto read results back
With TypeGPU, the layout object replaces the numeric indices, and .read() takes care of staging.
Limits, Escape Hatches and Browser Support
TypeGPU is pre-1.0. The 0.12 release line shipped on August 12, 2026, and minor versions have removed deprecated APIs, so read the 0.12 migration guide and later guides before you upgrade. The library is MIT-licensed and developed by Software Mansion.
Drop to raw WebGPU when you need to. You can wrap an existing GPUBuffer with root.createBuffer(schema, existingBuffer) to get typed reads and writes. You then own that buffer’s lifecycle and must make sure its contents match the schema. To call .write(), it also needs COPY_DST:
const raw = root.device.createBuffer({
size: 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const counter = root.createBuffer(d.u32, raw);
counter.write(42);
Guard indices. WebGPU keeps out-of-bounds accesses from reaching memory outside the binding, but it doesn’t make them correct. Guard the index any time the dispatch size can be larger than the array.
Avoid cross-thread reads. GPU threads run in no guaranteed order. If one thread reads an element that another thread writes in the same dispatch, the result is nondeterministic:
// Nondeterministic: thread x reads index x + 1 while thread x + 1 may be writing it
const smear = root.createGuardedComputePipeline((x) => {
'use gpu';
if (x + 1 >= N) {
return;
}
layout.$.values[x] = layout.$.values[x] + layout.$.values[x + 1];
});
WebGPU also caps how many workgroups you can dispatch along a single axis. For large grids, split the work across x and y.
Browser support. TypeGPU runs wherever WebGPU does. Check current WebGPU coverage on caniuse for your target audience.
Know when not to use it. TypeGPU’s own docs recommend a rendering framework for putting an interactive 3D model on a page. For that job, building 3D animations with Three.js is the better fit.
Conclusion
TypeGPU fixes the part of WebGPU where bugs are hardest to track down, the boundary between JavaScript data and shader memory, by describing both sides with one TypeScript schema and type-checking every binding. Start by adding unplugin-typegpu to a Vite project and running the doubling example. Then try passing a mismatched buffer to createBindGroup to see the compiler catch it.
FAQs
Can I use my existing WGSL shaders with TypeGPU?
Yes. The tgpu-gen CLI reads existing WGSL shader files and generates TypeScript files containing matching TypeGPU definitions. It supports glob patterns and a watch mode that regenerates definitions when the shaders change. TypeGPU function shells also take their implementation as either a WGSL string or a JavaScript function, so you can move a codebase over one function at a time.
Can a 'use gpu' function also run on the CPU?
Yes. A function marked with 'use gpu' is still a normal TypeScript function, so you can call it directly in JavaScript and get a result back. You can also pass it to tgpu.resolve to generate WGSL, or run it on the GPU through a pipeline. This lets you unit-test shader helper logic in your usual JavaScript test runner before it runs on the GPU.
How do I get the generated WGSL out of TypeGPU?
Call tgpu.resolve with the TypeGPU resources you need, such as 'use gpu' functions and schemas. It returns a ready-to-use WGSL string that includes all their transitive dependencies. This is useful when another WebGPU engine or material system expects raw WGSL. You don't need to call resolve yourself when you use TypeGPU pipelines, because they resolve and compile the shader internally.
Does TypeGPU work in React Native?
Yes. TypeGPU runs in React Native through the react-native-webgpu package. To write 'use gpu' shaders, add unplugin-typegpu/babel to the plugins list in babel.config.js, then clear the Metro cache if needed. React Native WebGPU does not run in Expo Go, so an Expo project needs expo prebuild. On the iOS simulator, you also have to turn off Metal API Validation in Xcode.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k