Tutorial · TypeScript · Three.js · Tooling
📅 July 2026 ⏱ ≈ 20 min 🎯 Intermediate

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
  }
}
Why 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;
}
Walking the scene graph safely: 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`);
    }
  }
}
The 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 };
}
Fail loudly on shape mismatch: throwing when the runtime scene graph doesn't match the expected 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

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.