Composing Powerful TypeScript Interfaces
With a game rendering scene example
TypeScript’s ability to compose behaviors from multiple types through interfaces is a powerful feature. These contracts provide an abstraction, interacting only via the interface without concern of classes.
Since classes can implement multiple interfaces, this eases complexities of inheritance chains through base classes.
Interfaces also define polymorphism, enabling multiple classes to enforce behaviors unrelated to implementation. Classes that realize the same interface may be substituted for one another.
Able interfaces
.NET Framework conventions of able-interfaces always resonated with me — things like IEquatable
or IComparable
that turn the action of the interface into an adjective by adding the “able” suffix.
For example, say you have a drawing application that consists of entities that will perform their own discreet rendering operations.
This render()
function will exist in an IRenderable
interface:
export interface IRenderable {
render(): void;
}
Multiple classes can implement this interface, such as Circle
and Rectangle
:
export class Circle implements IRenderable {
render(): void {
console.log("Circle rendering code here...")
}
}
export class Rectangle implements IRenderable {
render(): void {
console.log("Rectangle rendering code here...")
}
}
But the power here is eliminating concern of those concrete classes via the interface type. For example, say you want to reference a single shape:
const shape: IRenderable = new Circle();
shape.render();
Or, change the class instance to a different class that enforces the same contract as specified by the interface:
let shape: IRenderable;
shape = new Circle();
shape.render();
shape = new Rectangle();
shape.render();
A practical example of this would be adding shapes to a collection, where at a later time you render the entire collection out to the canvas:
const shapes: IRenderable[] = [
new Circle(),
new Rectangle()
];
for (let shape of shapes) {
shape.render();
}
A full example, leveraging PIXI for drawing:
export interface IRenderable {
render(graphics: PIXI.Graphics): void;
}
export class Circle implements IRenderable {
public radius: number = 100;
render(graphics: PIXI.Graphics): void {
graphics.drawCircle(0, 0, this.radius);
}
}
export class Rectangle implements IRenderable {
public width: number = 100;
public height: number = 100;
render(graphics: PIXI.Graphics): void {
graphics.drawRect(0, 0, this.width, this.height);
}
}// Define shapes and graphics context to render upon
const shapes: IRenderable[] = [new Circle(), new Rectangle()];
const graphics = new PIXI.Graphics();
for (let shape of shapes) {
shape.render(graphics);
}
Composing Interfaces
Say you want to compose a game scene with a rendering engine — let’s look at Pixi’s built-in ticker vs a custom animation loop.
Our game scene interface will accept a generic type of engine:
export interface IGameScene<T> {
engine: T;
}
Our engine interface simply needs a start()
function to starting the rendering engine process:
export interface IEngine {
start(): void;
}
Our first engine will use the default Ticker
provided by Pixi — we’ll call this the TickerEngine
which will implement the IEngine
interface:
export class TickerEngine implements IEngine {
start(): void {
console.log("Starting ticker engine...");
const renderer = new PIXI.Renderer();
const scene = new PIXI.Container();
const ticker = new PIXI.Ticker();
ticker.add(() => {
console.log("ticker frame handler");
renderer.render(scene);
}, PIXI.UPDATE_PRIORITY.LOW);
ticker.start();
}
}
Our second engine will use a custom animation frame handler — we’ll call this the LoopEngine
which also implements the IEngine
interface:
export class LoopEngine implements IEngine {
renderer = new PIXI.Renderer();
scene = new PIXI.Container();
start(): void {
console.log("Starting loop engine...");
requestAnimationFrame(this.frameHandler);
}
private frameHandler = () => {
console.log("loop frame handler");
this.renderer.render(this.scene);
requestAnimationFrame(this.frameHandler);
};
}
Now the game scene — it will implement the IGameScene
interface of type IEngine
. This enables us to specify the engine we want to use and automatically start the engine upon construction:
export class Scene implements IGameScene<IEngine> {
engine: IEngine;
constructor(engine: IEngine) {
this.engine = engine;
this.engine.start();
}
}
Let’s try it out, starting with the ticker engine:
const scene = new Scene(new TickerEngine());

Next, the custom loop engine:
const scene = new Scene(new LoopEngine());

This approach enables us to compose behaviors together with common contracts, establishing familiar shapes of code.
Further Encapsulation
Say you were evaluating two physics engines for your game, and wanted to swap between them. In your app’s animation frame handler, you want to simply call step()
to invoke one step of the physics engine calculations. Start by defining your API as:
export interface IPhysicsEngine {
step(): void;
}
Let’s say you found a Box 2D physics engine library that works with your graphics framework — create a wrapper around it to execute the frame step:
export class Box2DPhysicsEngine implements IPhysicsEngine {
step(): void {
// Box 2D physics engine world step implementation
}
}
Maybe you want to compare with Planck’s TypeScript rewrite of Box2D that also works with your graphics framework — create a wrapper around that as well to execute the frame step:
export class PlanckPhysicsEngine implements IPhysicsEngine {
step(): void {
// Planck physics engine world step implementation
}
}
Within your game, instantiate any physics engine from your wrappers and simply reference the interface step()
within the frame handler:
export class GameScene {
private physicsEngine: IPhysicsEngine;
constructor(physicsEngine: IPhysicsEngine) {
this.physicsEngine = physicsEngine;
}
private frameHandler = () => {
this.physicsEngine.step();
requestAnimationFrame(this.frameHandler);
};
}
Try your game with Box2D:
const game = new GameScene(new Box2DPhysicsEngine());
Or, with Planck:
const game = new GameScene(new PlanckPhysicsEngine());
Of course, swapping core functionality can range from complex to impossible¹ depending on APIs and awareness required by dependent components. It would be interesting to explore JEE or Spring design patterns, inversion of control, and dependency injection in concert with generics and interfaces through a bean-style encapsulation.
Something such as composing models through two different 3D frameworks — I’ll give that a try in a future article.
Reducing Complexity
Describing connections with types helps to simplify your code by only requiring the interface needed for a specific implementation step.
Consider an asteroids style game with polygons floating in an euclidean space, mapped by cartesian coordinates and rotation. Maybe the polygon side count reduces after hit tests.
We’ll keep the IRenderable
interface, as these polygons will render themselves:
export interface IRenderable {
render(graphics: PIXI.Graphics): void;
}
Our shapes will have 2D point position and angle of rotation, and we’ll define polygons that will need the number of sides and radius. Let’s define those as separate interfaces.
export interface IPosition {
x: number;
y: number;
}export interface IRotation {
angle: number;
}export interface IPolygon {
sides: number;
radius: number;
}
Our Shape
class will implement position and rotation, without concern of how the shape is rendered:
export class Shape implements IPosition, IRotation {
x: number = 0;
y: number = 0;
angle: number = 0;
}
The concrete Polygon
class will inherit Shape
with interfaces IPosition
and IRotation
adding IPolygon
interface with an IRenderable
implementation:
export class Polygon extends Shape implements IPolygon, IRenderable {
sides: number;
radius: number; constructor(sides: number, radius: number) {
super();
this.sides = sides;
this.radius = radius;
} render(graphics: PIXI.Graphics): void {
let step = (Math.PI * 2) / this.sides;
let start = (this.angle / 180) * Math.PI;
let n, dx, dy; graphics.moveTo(
this.x + Math.cos(start) * this.radius,
this.y - Math.sin(start) * this.radius
); for (n = 1; n <= this.sides; ++n) {
dx = this.x + Math.cos(start + step * n) * this.radius;
dy = this.y - Math.sin(start + step * n) * this.radius;
graphics.lineTo(dx, dy);
}
}
}
These polygons could be instantiated as:
const triangle = new Polygon(3, 100);
const square = new Polygon(4, 100);
const pentagon = new Polygon(5, 100)
const hexagon = new Polygon(6, 100);
Or, defined as classes:
export class Triangle extends Polygon {
constructor(radius: number) {
super(3, radius);
}
}export class Square extends Polygon {
constructor(radius: number) {
super(4, radius);
}
}export class Pentagon extends Polygon {
constructor(radius: number) {
super(5, radius);
}
}
Now that we’ve defined these interfaces, we can simply forget all this complexity and focus on the contracts we need for specific tasks.
Our rendering pipeline really only cares about the IRenderable
interface:
const shapes: IRenderable[] = [triangle, square, pentagon, hexagon];
const graphics = new PIXI.Graphics();for (let shape of shapes) {
shape.render(graphics);
}
Maybe in a hit test function we need to calculate the area of a polygons —we can can use the IPolygon
interface to access the relevant properties of radius
and sides
:
/** Calculate area of a polygon */
export const area = (polygon: IPolygon): number => {
const r = polygon.radius;
const n = polygon.sides; return (n * Math.pow(r, 2)) / (4 * Math.tan(Math.PI / n));
};
const polygons: IPolygon[] = [
new Triangle(100),
new Square(100),
new Pentagon(100)
];for (let polygon of polygons) {
console.log(`Area: ${area(polygon)}`);
}
Maybe each time the polygon is hit, the number of sides is reduced by one until it is destroyed. If a hit was successful, the IPolygon
interfaces provides the data needed:
const hit = (shape: IPolygon) => {
shape.sides -= 1; if (shape.sides < 3) {
console.log("Enemy destroyed!");
}
};
Maybe mouse clicks just need to access the position of the shape — that would be possible via the IPosition
interface:
export const onMouseDown = (position: IPosition) => {
console.log(position.x, position.y);
};
Mouse coordinate share a similar shape as the IPosition
interface, able to compare x, y coordinates between the mouse event and the shape position.
Interfaces help to decouple logic, simplify, and add clarity to contracts between connected code.
¹ nothing is impossible, but probably not worth the investment.
All problems in computer science can be solved by another level of indirection.
– David Wheeler