logo
By Lawrence

7 minutes

Generics

Two lessons ago you read emit<T = any>(type: string, data?: T): void and skipped the <T>. Last lesson you wrote classes. This lesson fills in the blank, using the same EventBus class the framework's systems use to talk to each other.

The problem any leaves you with

Say you want a helper that hands back whatever you give it. It should work for a number, a string, anything. The quick way is any, the type that switches checking off:

src/main.ts
function identity(value: any): any {
  return value;
}

let score = identity(10);
console.log(score.toUpperCase());

npx tsc prints nothing. The code type-checks. The browser disagrees, with Uncaught TypeError: score.toUpperCase is not a function. score is the number 10, and toUpperCase is a string method. any bought reuse and gave up every check on the way out.

A type parameter is a variable for a type

Here's the same helper as a genericA function, class or type written with a type parameter, so one definition stays type-safe for many types.:

src/main.ts
function identity<T>(value: T): T {
  return value;
}

let score = identity(10);
let name = identity("Aria");

console.log(name.toUpperCase());
console.log(score.toUpperCase());
$ npx tsc
src/main.ts(9,19): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

<T> after the function name declares a type parameterThe placeholder in angle brackets, like the T in <T>, that stands for a type until one is filled in.. value holds whatever value you pass; T holds whatever type you pass. Nobody wrote identity<number>(10). The compiler read the argument and set T to number, the same inference as in the types lesson. So score is a number, name is a string, name.toUpperCase() is fine (the console shows ARIA), and the bad line is caught.

You've used generics already. number[] from the types lesson is shorthand for Array<number>. A Set<string> is a set of unique strings. A Map<string, number> has two parameters: string keys, number values.

How the EventBus uses T

A dark navy circuit-board diagram, glowing cyan neon wireframe, showing a single labeled "EventBus" box in the center with thin glowing pipes flowing into it from many small differently-shaped data packets (a number tile, a coordinate pair, a struct), each pipe tagged with a bracketed angle-bracket label, isometric, blueprint aesthetic, no text clutter

An EventBus carries named messages. One part of the game calls emit("score.changed", data) and every part that called on("score.changed", ...) gets the data. The bus was written before your game existed, so it can't know what your data looks like. The first lines of EventBus.ts show how it copes:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
/**
 * Type for event listener callback functions.
 * T is the type of data passed with the event.
 */
export type EventCallback<T = any> = (data: T) => void;

An EventCallback<T> is a function that takes data of type T and returns nothing. = any is a default: leave T out and it becomes any. Now the method that subscribes:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
on<T = any>(type: string, callback: EventCallback<T>): UnsubscribeFn {

The T you give on flows into the callback. Try it:

src/main.ts
import { EventBus } from "@babylonjsmarket/ecs";

const bus = new EventBus();

bus.on<{ score: number }>("score.changed", (d) => {
  console.log("score is", d.score);
});

bus.emit("score.changed", { score: 10 });

The console prints score is 10. Hover d in your editor and it shows { score: number; }, the type you put in the angle brackets.

Catching a typo in the payload

Now make the typo everyone makes. Change the log line to read d.scor:

src/main.ts
bus.on<{ score: number }>("score.changed", (d) => {
  console.log("score is", d.scor);
});
$ npx tsc
src/main.ts(6,29): error TS2551: Property 'scor' does not exist on type '{ score: number; }'. Did you mean 'score'?

The compiler even offers the fix. Now delete <{ score: number }> and keep the typo:

src/main.ts
bus.on("score.changed", (d) => {
  console.log("score is", d.scor);
});

npx tsc passes. Hover d and it says any, because T fell back to its default. The browser logs score is undefined. No error, no crash, just a scoreboard that shows nothing. That's the trade any makes, seen from both sides of the same bus.

Typing both ends with one alias

emit has a T too. Name the payload once with a type alias and use it on both ends:

src/main.ts
import { EventBus } from "@babylonjsmarket/ecs";

type ScoreChanged = { score: number };

const bus = new EventBus();

bus.on<ScoreChanged>("score.changed", (d) => {
  console.log("score is", d.score);
});

bus.emit<ScoreChanged>("score.changed", { score: 10 });
bus.emit<ScoreChanged>("score.changed", { score: "ten" });

The last emit sends "ten", and npx tsc rejects it: src/main.ts(12,43): error TS2322: Type 'string' is not assignable to type 'number'. The browser still ran it and logged score is ten, like every other type error in this course.

ScoreChanged only lives in this file, so another file could still spell the event name or the payload differently. The next course keeps event names and payload types in one shared place for that reason.

A generic method with a constraint

The framework uses the same trick for components. This is the method you called as hero.get(Position) in the classes lesson:

@babylonjsmarket/ecs/src/ECS/Entity/Entity.ts
get<T extends Component>(ComponentClass: ComponentType<T>): T | undefined {

Read it left to right. T extends Component is a constraint: T can be anyThe type that switches checking off. Reuse without safety. type, as long as it is a component. You pass the class, and you get back an instance of that same class, or undefined if the entity doesn't have one. Watch the type follow the class you pass:

src/main.ts
import { Component, World } from "@babylonjsmarket/ecs";

class Position extends Component {
  x = 0;
}

class Health extends Component {
  hp = 100;
}

const world = new World();
const hero = world.createEntity("hero").add(Position);

const pos = hero.get(Position);
const health = hero.get(Health);

console.log(pos?.x, health?.hp);
console.log(pos?.hp);
$ npx tsc
src/main.ts(18,18): error TS2339: Property 'hp' does not exist on type 'Position'.

Hover pos and it says Position | undefined; hover health and it says Health | undefined. One method, and each call knows exactly what came back. The console logs 0 undefined, because the hero has a position but no health. The last line asks a Position for hp, and the compiler stops it.

The listener map, one generic at a time

Generics nest. This is where the bus keeps every subscription:

@babylonjsmarket/ecs/src/ECS/EventBus/EventBus.ts
/**
 * Map of event type -> Set of callback functions.
 * Using Set ensures each callback is only registered once per event.
 */
private listeners: Map<string, Set<EventCallback>> = new Map();

Read it outside in. A Map, whose keys are string event names and whose values are a Set of callbacks. So listeners.get("score.changed") is the set of functions to call when that event fires, and the Set means the same function can't be added twice. private keeps other code from touching the map directly; everything goes through on and emit.

A dark navy schematic, glowing magenta and cyan neon wireframe, depicting a Map drawn as a row of labeled key slots on the left connected by glowing lines to small bins on the right, each bin holding several identical function tokens, the whole thing framed like an engineering blueprint, isometric, clean, minimal text

In Sum

any let score.toUpperCase() compile and then crash. identity<T> kept the type, and tsc caught the same call with TS2339. hero.get(Position) came back typed as a Position. On the real EventBus, on<{ score: number }> caught d.scor with TS2551, and without the type argument the same typo logged undefined silently.

Next: Interfaces and Type-Only Imports looks at the other kind of contract: a named shape many objects can match.

Was this page helpful?

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

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search