logo
By Lawrence

7 minutes

Interfaces and Type-Only Imports

Last lesson, Generics, made one definition work for many types. This lesson covers the other half: one shape that many objects can promise to match. That shape is an interfaceA named list of the fields and methods an object must have, with no code of its own.. Along the way you hit the import type rule that create-vite's tsconfig.json switched on.

Writing an interface

An interface names the fields an object must have. Paste this into src/main.ts:

src/main.ts
interface Pickup {
  name: string;
  points: number;
  respawns?: boolean;
}

function describe(p: Pickup): string {
  return `${p.name} is worth ${p.points}`;
}

console.log(describe({ name: "coin", points: 10 }));
console.log(describe({ name: "gem", points: 50, respawns: true }));
console.log(describe({ name: "key" }));
$ npx tsc
src/main.ts(13,22): error TS2345: Argument of type '{ name: string; }' is not assignable to parameter of type 'Pickup'.
  Property 'points' is missing in type '{ name: string; }' but required in type 'Pickup'.

The coin and the gem match Pickup. respawns? has a ?, so it's optional, and the coin can leave it out. The key has no points, and the error says exactly which field is missing. In the browser the third line logs key is worth undefined.

An interface has no code. It's only a description, and like every type it's erased before the browser runs anything.

A class can promise to match an interface with the keyword implements. Break the promise and the compiler names the missing piece:

src/main.ts
interface Pickup {
  name: string;
  points: number;
  respawns?: boolean;
}

class Coin implements Pickup {
  name = "coin";
}

console.log(new Coin());

npx tsc reports error TS2420: Class 'Coin' incorrectly implements interface 'Pickup'. and then Property 'points' is missing in type 'Coin' but required in type 'Pickup'. Add points = 10; to the class and it passes.

The framework's query interface

Every system in the framework declares a query: which entities it wants. The shape of that query is an interface:

@babylonjsmarket/ecs/src/ECS/System/System.ts
export interface ISystemQuery {
  /** Entity must have ALL of these components */
  required?: ComponentType[];

  /** Entity must have NONE of these components */
  excluded?: ComponentType[];

  /** Entity must have AT LEAST ONE of these components */
  anyOf?: ComponentType[];

  /** Entity must have ALL of these tags */
  tags?: string[];

  /** Entity must have NONE of these tags */
  excludedTags?: string[];
}

Five optional fields, each a list. ComponentType[] is a list of component classes, like the [Position] you wrote in the classes lesson. Now get it wrong twice:

src/main.ts
import { Component } from "@babylonjsmarket/ecs";
import type { ISystemQuery } from "@babylonjsmarket/ecs";

class Position extends Component {
  x = 0;
}

const good: ISystemQuery = { required: [Position] };
const typo: ISystemQuery = { requird: [Position] };
const notAList: ISystemQuery = { required: Position };

console.log(good, typo, notAList);
$ npx tsc
src/main.ts(9,30): error TS2561: Object literal may only specify known properties, but 'requird' does not exist in type 'ISystemQuery'. Did you mean to write 'required'?
src/main.ts(10,34): error TS2740: Type 'typeof Position' is missing the following properties from type 'ComponentType[]': pop, push, concat, join, and 34 more.

The typo gets a "did you mean". The second error is noisier, but it says the same thing in its own way: Position isn't a list, because a list would have push and pop. Put it in brackets and it passes. Without the interface, requird would be a harmless extra field, and the system would quietly match every entity.

Branded handles can't be mixed up

In the types lesson, EntityId accepted any string. The framework's renderer, the part that draws, needs something stricter. When it makes a mesh (a 3D shape) or a light, it hands back a handleAn opaque token that stands for something another piece of code owns, like a mesh inside a 3D engine., a token that stands for the real object inside Babylon.js or Three.js, the two 3D engines it can draw with. Passing a mesh handle where a light handle belongs has to fail:

@babylonjsmarket/ecs/src/Renderer/types.ts
export type MeshHandle = { readonly __mesh: unique symbol };
export type LightHandle = { readonly __light: unique symbol };

Each handle type has one field that exists only for the compiler. unique symbol is a type that matches nothing but itself, so a MeshHandle can never pass for a LightHandle. This trick is called a branded typeA type made unique by a marker field that exists only for the compiler, so two lookalike types can't be mixed up.. Get a real handle and try it:

src/main.ts
import { MockRendererAdapter } from "@babylonjsmarket/ecs";
import type { LightHandle, MeshHandle } from "@babylonjsmarket/ecs";

const renderer = new MockRendererAdapter();
const box: MeshHandle = renderer.createMesh("box", { kind: "box" });

function dim(light: LightHandle): void {
  console.log("dimming", light);
}

console.log("box handle:", box);
dim(box);
$ npx tsc
src/main.ts(12,5): error TS2345: Argument of type 'MeshHandle' is not assignable to parameter of type 'LightHandle'.
  Property '__light' is missing in type 'MeshHandle' but required in type 'LightHandle'.

MockRendererAdapter is a stand-in renderer that draws nothing, used for tests. The browser console shows the handle is really {__mockHandle: 'mesh:box'}. The __mesh field was never there. It was a type note, and the compiler used it to tell two shapes apart.

One interface, four renderers

The renderer itself is an interface. Here's its first line:

@babylonjsmarket/ecs/src/Renderer/types.ts
export interface RendererAdapter {
  readonly kind: 'babylon' | 'three' | 'babylon-lite';

'babylon' | 'three' | 'babylon-lite' is a union: kind must be one of those three strings. Below it sit well over a hundred methods, like createMesh. Four classes implement it: BabylonAdapter, BabylonLiteAdapter, ThreeAdapter and the mock, which starts export class MockRendererAdapter implements RendererAdapter. The compiler checks every one of those methods against each class. Code that only asks for a RendererAdapter works with any of the four. The real ones come from their own entry points, separate import paths inside the package: @babylonjsmarket/ecs/babylon, @babylonjsmarket/ecs/babylon-lite and @babylonjsmarket/ecs/three, because each needs its 3D engine installed. The mock needs nothing, so it's the one you can run now.

import type and verbatimModuleSyntax

Type the renderer as the interface and import both names the obvious way:

src/main.ts
import { MockRendererAdapter, RendererAdapter } from "@babylonjsmarket/ecs";

const renderer: RendererAdapter = new MockRendererAdapter();
console.log("renderer kind:", renderer.kind);
$ npx tsc
src/main.ts(1,31): error TS1484: 'RendererAdapter' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

The browser stops before running a line:

Uncaught SyntaxError: The requested module '/node_modules/.vite/deps/@babylonjsmarket_ecs.js?v=...' does not provide an export named 'RendererAdapter'

RendererAdapter is a type, so the package's JavaScript has no export by that name. The browser only asks for it because verbatimModuleSyntax is on in your tsconfig.json. With it on, Vite keeps every import exactly as written and erases only what you marked as a type. Delete that line from tsconfig.json and this same file runs, because Vite then drops names it sees used only as types. create-vite turns the setting on so what gets erased is written in the source instead of guessed, and TS1484 is tsc telling you before the browser does. Put the setting back and split the import in two:

src/main.ts
import { MockRendererAdapter } from "@babylonjsmarket/ecs";
import type { RendererAdapter } from "@babylonjsmarket/ecs";

const renderer: RendererAdapter = new MockRendererAdapter();
console.log("renderer kind:", renderer.kind);

import type is erased completely. npx tsc passes, and the console logs renderer kind: babylon, since the mock reports itself as Babylon.

In Sum

Your own Pickup interface caught a missing field with TS2345. ISystemQuery caught requird with TS2561 and a missing list with TS2740. Branded handles turned a mesh passed as a light into a compile error, and a plain import of RendererAdapter gave TS1484 in tsc and a SyntaxError in the browser until it became import type.

Next: What a Computer Can Do in 16 Milliseconds leaves the compiler behind and measures how much work fits in one frame.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search