TypeScript for Three.js: Typing a Scene
Three.js's own types cover the API surface well, but a simulation's domain types — particle state, uniform shapes, loaded-asset shapes — are up to you. This tutorial covers the TypeScript patterns that catch real bugs in a WebGL codebase: typed scene-graph nodes, generic buffer attributes, discriminated unions for body state, and strongly-typed shader uniforms.
1. Project setup and strict tsconfig
Since r150, Three.js ships its own TypeScript declaration files —
@types/three is deprecated for current versions,
though you may still see it pinned in older projects.
npm install three
npm install -D typescript vite
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}
noUncheckedIndexedAccess matters here:
simulation code constantly indexes typed arrays
(positions[i * 3]) — without this flag TypeScript
assumes the index always exists and hides real out-of-bounds
bugs that show up as silent NaN propagation in a
render.
2. Typing the scene graph
THREE.Mesh is generic over its geometry and material
types since r152 — use the generics so
mesh.geometry and mesh.material keep
their concrete type instead of collapsing to
BufferGeometry | Material:
import * as THREE from 'three';
// Concrete geometry + material types flow through .geometry / .material
const geometry = new THREE.SphereGeometry(1, 32, 32);
const material = new THREE.MeshStandardMaterial({ color: 0x60a5fa });
const mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshStandardMaterial>
= new THREE.Mesh(geometry, material);
mesh.material.roughness = 0.4; // typed — no cast needed
// Custom userData needs its own interface — Three.js types it as `any`
interface SimBodyUserData {
bodyId: number;
mass: number;
}
function getBodyId(obj: THREE.Object3D): number | undefined {
const data = obj.userData as Partial<SimBodyUserData>;
return data.bodyId;
}
object.children is typed as
Object3D[], so
scene.traverse(obj => { ... }) gives you a plain
Object3D — narrow with
obj instanceof THREE.Mesh before touching
.geometry or .material.
3. Typed BufferGeometry attributes
BufferAttribute is generic over its backing typed
array. Reading it back with the wrong assumed type is a common
source of silent precision bugs (e.g. treating a
Float32Array attribute as if it held integers).
function getPositionAttribute(
geometry: THREE.BufferGeometry,
): THREE.BufferAttribute {
const attr = geometry.getAttribute('position');
if (!(attr instanceof THREE.BufferAttribute)) {
throw new Error('position attribute missing or interleaved');
}
return attr;
}
function displaceVertices(geometry: THREE.BufferGeometry, dt: number): void {
const pos = getPositionAttribute(geometry);
const arr = pos.array as Float32Array; // known layout for this geometry
for (let i = 0; i < pos.count; i++) {
const y = arr[i * 3 + 1];
arr[i * 3 + 1] = y + Math.sin(dt) * 0.01;
}
pos.needsUpdate = true;
}
4. Discriminated unions for simulation state
Most simulations mix a few body "kinds" with different fields —
model that as a tagged union on a
kind field instead of one bag-of-optional-fields
interface. The compiler then forces you to handle every kind
explicitly.
type SimBody =
| { kind: 'particle'; position: THREE.Vector3; velocity: THREE.Vector3; mass: number }
| { kind: 'rigid'; position: THREE.Vector3; quaternion: THREE.Quaternion; inertia: THREE.Matrix3 }
| { kind: 'anchor'; position: THREE.Vector3 }; // static, never integrated
function integrate(body: SimBody, dt: number): void {
switch (body.kind) {
case 'particle':
body.position.addScaledVector(body.velocity, dt);
break;
case 'rigid':
integrateRigidBody(body, dt); // body is narrowed to the 'rigid' variant
break;
case 'anchor':
break; // static — nothing to do
default: {
const _exhaustive: never = body; // compile error if a new kind is added and unhandled
throw new Error(`Unhandled body kind`);
}
}
}
never exhaustiveness check: add a
fourth SimBody variant later and this
default branch fails to compile until you add a
matching case — a cheap way to make sure new body
types can't silently fall through unintegrated.
5. Typed shader uniforms
ShaderMaterial types its uniforms
field as a loose { [name: string]: IUniform } —
define your own uniform shape once and reuse it, so a typo in a
uniform name is a compile error, not a silent
undefined in GLSL.
interface WaveUniforms {
uTime: THREE.IUniform<number>;
uAmplitude: THREE.IUniform<number>;
uColorA: THREE.IUniform<THREE.Color>;
uColorB: THREE.IUniform<THREE.Color>;
}
const uniforms: WaveUniforms = {
uTime: { value: 0 },
uAmplitude: { value: 0.3 },
uColorA: { value: new THREE.Color(0x1e293b) },
uColorB: { value: new THREE.Color(0x60a5fa) },
};
const material = new THREE.ShaderMaterial({
uniforms, // structurally compatible with Record<string, IUniform>
vertexShader,
fragmentShader,
});
function updateWave(elapsed: number): void {
uniforms.uTime.value = elapsed; // typo-safe: uniforms.uTme would fail to compile
}
6. Typing GLTF and asset loaders
GLTFLoader.loadAsync() resolves to a generic
GLTF object — its scene is
THREE.Group, so any mesh you expect inside it still
needs a runtime type guard, not just a cast.
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
interface LoadedRobotAsset {
scene: THREE.Group;
armMesh: THREE.Mesh;
clips: THREE.AnimationClip[];
}
async function loadRobot(url: string): Promise<LoadedRobotAsset> {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(url);
const armMesh = gltf.scene.getObjectByName('Arm');
if (!(armMesh instanceof THREE.Mesh)) {
throw new Error('Expected "Arm" node to be a Mesh');
}
return { scene: gltf.scene, armMesh, clips: gltf.animations };
}
LoadedRobotAsset shape turns a broken/renamed export
from your 3D tool into an immediate, readable error instead of a
Cannot read properties of undefined deep inside the
render loop.
7. Common typing pitfalls
-
Mixing
@types/threewith r150+ built-in types: having both installed produces duplicate/conflicting declarations — remove@types/threeonce yourthreeversion ships its own types (r152+). -
Casting
Object3DtoMeshwithout a guard: an uncheckedas THREE.Meshcompiles fine and then throws at runtime on the first non-mesh child — preferinstanceofnarrowing everywhere you traverse the scene. -
Typing
dispose()calls as complete: TypeScript happily lets you callgeometry.dispose()without warning that materials/textures attached to a mesh need separate disposal — a type system won't catch a GPU memory leak, only a runtime profiler will. -
Over-generic helper functions: a function typed
as
function setUniform<T>(m: THREE.ShaderMaterial, k: string, v: T)defeats the purpose — prefer the concreteWaveUniforms-style interface from section 5 so invalid uniform names are caught at the call site. -
Vector/Quaternion methods mutate in place: not
a typing bug per se, but TypeScript won't stop you from writing
const next = body.position.add(velocity)expecting an immutable result —.add()mutatesbody.positionand returnsthis; use.clone().add(...)when you need a new vector.
Frequently Asked Questions
What will I learn in this tutorial?
Add TypeScript to a Three.js project: typing Object3D hierarchies, generic BufferGeometry attributes, discriminated unions for simulation state, and typed uniforms for ShaderMaterial.
What topics are covered in this tutorial?
This tutorial covers: Project setup and strict tsconfig, Typing the scene graph, Typed BufferGeometry attributes, Discriminated unions for simulation state, Typed shader uniforms, Typing GLTF and asset loaders, Common typing pitfalls.
How long does this tutorial take?
This tutorial takes approximately 20 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.