Creating flexible TypeScript interfaces
With game scene rendering example

The ability of TypeScript to define behavior using multiple interfaces is very powerful. This feature provides an abstraction, interaction only through interfaces without the use of classes.
The ability to implement multiple interfaces solves some of the inheritance difficulties that arose with using regular classes.
Interfaces also define polymorphism, allowing different classes to define behavior that is not related to the implementation of the interface. Classes that implement the same interface can replace each other.
Able interfaces
The .NET Framework conventions about able interfaces are always associated with things by type IEquatable
or IComparable
, which denote the action of the interface by an adjective with the suffix “my”.
For example, you have a drawing application that consists of some entities that implement their own render operations.
This function render()
will exist in IRenderable
interface:
export interface IRenderable {
render(): void;
}
Several classes like Circle
and Rectangle
, can implement this interface:
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...")
}
}
The convenience is not to use concrete classes as a type, but to use an interface. For example, you want to create some shape:
const shape: IRenderable = new Circle();
shape.render();
Or change the shape type to another class that implements the same interface:
let shape: IRenderable;
shape = new Circle();
shape.render();
shape = new Rectangle();
shape.render();
A practical example is adding shapes to a collection, which will then be processed for output to the canvas.
const shapes: IRenderable[] = [
new Circle(),
new Rectangle()
];
for (let shape of shapes) {
shape.render();
}
Full example using the PIXI library:
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);
}
}
// Определение фигур и контекста для графики
const shapes: IRenderable[] = [new Circle(), new Rectangle()];
const graphics = new PIXI.Graphics();
for (let shape of shapes) {
shape.render(graphics);
}
Creating interfaces
Let’s say you want to create a game scene using a render engine – let’s take a look at PIXI’s built-in engine and our own.
Our interface for the game scene will contain a generic type engine:
export interface IGameScene<T> {
engine: T;
}
The engine interface will have a function start()
, which will start the rendering process:
export interface IEngine {
start(): void;
}
The first engine will use the normal Ticker
engine, which is in PIXI. Let’s call this engine TickerEngine
, it will implement 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();
}
}
The second engine will use a custom handler. Let’s call this engine LoopEngine
, which will also implement 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);
};
}
The game scene class will implement IGameScene
type interface IEngine
. This will allow us to specify the specific engine that we will use and automatically run in the constructor:
export class Scene implements IGameScene<IEngine> {
engine: IEngine;
constructor(engine: IEngine) {
this.engine = engine;
this.engine.start();
}
}
Now let’s create an instance of the game scene using TickerEngine
:
Let’s do the same, but now with LoopEngine
:
This approach allows us to create similar behavior for different entities.
Further Encapsulation
Let’s say you’re evaluating two physics engines for a game and want to change them. In the animation handler in your application, you want to use the function step()
to call engine calculations one at a time. Let’s start by defining an interface:
export interface IPhysicsEngine {
step(): void;
}
Let’s say you found a physics engine library Box 2D, which interacts with your graphics framework. Let’s create a wrapper around it that calls the following calculation:
export class Box2DPhysicsEngine implements IPhysicsEngine {
step(): void {
// Какая-то реализация движка Box 2D
}
}
Perhaps you want to compare Box 2D with Planck. Let’s create a wrapper around this engine:
export class PlanckPhysicsEngine implements IPhysicsEngine {
step(): void {
// Какая-то реализация движка Planck
}
}
Let’s create an instance of the physics engine in the game class. In the handler, we will refer to the instance and call the method step()
:
export class GameScene {
private physicsEngine: IPhysicsEngine;
constructor(physicsEngine: IPhysicsEngine) {
this.physicsEngine = physicsEngine;
}
private frameHandler = () => {
this.physicsEngine.step();
requestAnimationFrame(this.frameHandler);
};
}
Let’s try to create a game using the Box 2D engine:
const game = new GameScene(new Box2DPhysicsEngine());
And now with Planck:
const game = new GameScene(new PlanckPhysicsEngine());
Of course, changing core functionality can be difficult or impossible¹, depending on architecture and components. It can be interesting to explore different patterns using generic types, interfaces, and encapsulation.
Simplification
Describing relationships with types simplifies your code, requiring only one interface that will define something in common.
Consider a space-style game with polygons flying in open space that have their own coordinates and rotation. It is also likely that the number of sides of the polygon decreases after hitting it.
We will use the same interface IRenderable
:
export interface IRenderable {
render(graphics: PIXI.Graphics): void;
}
Our shapes will have two coordinates, an angle of rotation, as well as the number of sides and a radius. Let’s define three different interfaces for this:
export interface IPosition {
x: number;
y: number;
}
export interface IRotation {
angle: number;
}
export interface IPolygon {
sides: number;
radius: number;
}
The shape class will implement IPosition
and IRotation
interfaces without knowing how this figure will be processed later:
export class Shape implements IPosition, IRotation {
x: number = 0;
y: number = 0;
angle: number = 0;
}
The concrete polygon class will inherit the figure class, implement the interfaces that the figure class implements, and in addition also the interfaces IPolygon
and IRenderable
:
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);
}
}
}
All polygons can be created 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 separate 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 have defined all these interfaces, we can forget about all the complexities and focus on other tasks.
For our render handler, all that matters is that all shapes implement the interface IRenderable
:
const shapes: IRenderable[] = [triangle, square, pentagon, hexagon];
const graphics = new PIXI.Graphics();
for (let shape of shapes) {
shape.render(graphics);
}
Perhaps in polygon hit tests, we will need to calculate the area of that polygon. For this we can use the interface IPolygon
to access required fields raduis
and sides
:
/** Подсчет площади многоульника */
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)}`);
}
Perhaps each time the polygon is hit, the number of sides will decrease until the shape is destroyed:
const hit = (shape: IPolygon) => {
shape.sides -= 1;
if (shape.sides < 3) {
console.log("Enemy destroyed!");
}
};
Perhaps on click you will need to access the coordinates of the shape, for this you can use IPosition
interface:
export const onMouseDown = (position: IPosition) => {
console.log(position.x, position.y);
};
Mouse coordinates are similar to IPosition
interface structure and you can use it.
Interfaces help separate logic and make code easier to understand.
¹ nothing is impossible, but there are things that are not worth the investment.
All problems in computer science can be solved by another level of abstraction. — David Wheeler