Try out code as you go by creating a
.jsfile and running it with Bun at various points. And/or you may want to try things in Bun's REPL withbun replor in your browser's console.
In JavaScript, we've seen that it's very easy to create and use an object, and properties can be of any type, including functions:
let ball = {
x: 5,
move () {
ball.x += 2
console.log('moved', ball.x)
}
}
console.log(ball.x) // 5
ball.move() // "moved 7"
Notice how the ball has a move() method (a function within an object is called a method) inside it - it's functionality is encapsulated within the object, as is the x property. This makes the method more discoverable - you can type ball. and you may see move() listed as an option in an IDE. It also avoids polluting the global namespace with a bunch of functions and variables which may only apply to this object. Compare the above with an alternative where the method isn't encapsulated within the object:
let ball = { x: 5 }
function move (obj) {
obj.x += 2
console.log('moved', obj.x)
}
console.log(ball.x)
move(ball)
Of course there might be reasons to prefer the latter, as the move() function could be used on different types of objects too, such as squares or triangles, which makes it more flexible.
Read back over the objects guide for more.
But what if we want more than one ball? It seems a bit wasteful to have to type each one out with the method duplicated in each one, so we may try something like this:
function makeBall (x = 5) {
let ball = {
x: x,
move () {
ball.x += 2
console.log('moved', ball.x)
}
}
return ball
}
let ball1 = makeBall(3)
console.log(ball1.x)
ball1.move()
let ball2 = makeBall(8)
console.log(ball2.x)
ball2.move()
This works, and I actually quite like this approach. But it's not quite proper OOP yet. When we're doing OOP, we use objects in a slightly different way, creating them from blueprints or templates called classes.
When we're doing OOP, we have a new tool in our toolkit, called a class.
class Ball {
x = 5 // field/attribute/property
move () { // method
this.x += 2
console.log('moved', this.x)
}
}
let ball1 = new Ball(3)
console.log(ball1.x)
ball1.move()
let ball2 = new Ball(8)
console.log(ball2.x)
ball2.move()
In this class block we have the template or blueprint for creating ball objects. Variables inside a class are known as attributes (or fields or properties), and functions as methods. To refer to attributes and methods within a class we need to prefix them with this, eg this.x or this.move(). By convention, always start class names with a capital letter.
To make a new instance of a ball from this template, we need to use the new keyword.
let ball = new Ball() // instantiate a ball
Each ball's attributes belong only to itself, and we need to use the new keyword when creating (instantiating) them.
But our example isn't yet doing anything with the argument sent in new Ball(3). For that, we need to make use of a special method called a constructor.
When you create an object from a class, it looks for a method called constructor() (in some languages it's called new() or the same name as the class instead) and if there is one, it runs it with the arguments provided.
class Ball {
constructor (x = 5) {
this.x = x
}
move () {
this.x += 2
console.log('moved', this.x)
}
}
Now the argument sent in new Ball(3) is actually used:
let ball = new Ball(3)
console.log(ball.x)
ball.move()
The encapsulation a class provides is already a compelling enough reason to consider using classes. A concept called inheritance is another.
Sometimes we can think of a kind of thing which is similar to another thing but with a few differences. When that happens, we can create a new class that is based on our existing class but with some changes.
In our example, we're going to say that a ball is a type of shape, and extend a Shape superclass, creating a subclass called Ball.
// our [parent |base |super]class
class Shape {
constructor (x = 5, y = 10) {
this.x = x
this.y = y
}
moveTo (x, y) {
this.x = x
this.y = y
}
}
// our [child |sub]class
class Ball extends Shape {
r = 10
constructor (x, y, r) {
super(x, y) // calls the parent constructor()
this.r = r
}
draw () {
console.log(`Drawing ball with centre (${this.x}, ${this.y}) and radius ${this.r}`)
}
}
let shape = new Shape(5, 6)
console.log(shape.x) // 5
let ball = new Ball(20, 30, 5)
console.log(ball.x) // 20
ball.moveTo(30, 40)
console.log(ball.x) // 30
ball.draw()
Because Ball extends Shape, it inherits all the members (attributes and methods) of Shape. So because a shape has both x and y attributes, so do balls. But a ball has a radius r which other types of shapes don't, so we define that within the ball subclass.
Notice how in a ball's constructor, we call super(x, y) - this calls the parent class's constructor() method, which we want to do here to set the object's x and y attributes. But what if we also wanted to call the moveTo() method which is defined in the parent class?
Above we have the Ball class as a child of the parent Shape base class. All methods of the base class are available to the child class as super.methodName().
class Ball extends Shape {
// ...
reset () {
super.moveTo(0, 0) // calls the moveTo() method defined in the parent class
}
}
But maybe when moving a ball needs to do something different from a regular shape. There are 2 choices here.
Override the parent's method by creating a method with the same name in the subclass:
class Ball extends Shape {
// ...
moveTo (x, y) {
console.log(`The ball rolls to (${x}, ${y})`)
this.x = x
this.y = y
}
}
... here, because Ball has its own method moveTo(), this one is used for a ball instead of the method from the base class, which is ignored. Here's another option:
class Ball extends Shape {
// ...
moveTo (x, y) {
console.log(`The ball rolls to (${x}, ${y})`)
super.moveTo(x, y) // calls parent's moveTo() method
}
}
This time, we call super.moveTo() which runs the moveTo() method from the parent class. But we can still do other things after (or before) this, and if the parent class's method is really long, it saves us lots of needless duplication.
We may want to override the parent's constructor class, which takes some arguments. We can save a bit of coding by using ...args here:
class Ball extends Shape {
// ...
constructor (...args) {
super(...args) // call the parent's constructor with all the arguments
this.type = 'ball'
}
}
OOP is quite a bit different from procedural programming. Some love it, some hate it, some just take time to get used to this different paradigm. Some languages like Java force you into OOP, while JavaScript just provides it as an option. Some languages like C don't support it at all. My guess is you'll find it useful for some things (2D vectors is a common first use), but unnecessary for others. OOP comes up a lot in the exams, so it's important to give it a go even if it doesn't become your preferred programming paradigm.
Later in the year you'll learn about private vs public properties as well as getters and setters.
NOTE: exam questions will use inheritance not composition.
While inheritance is a core OOP concept, modern JavaScript often favours composition - combining small, focused behaviours rather than building deeply inherited hierarchies. Instead of saying "a ball is a shape", we can think "a ball has position and can move". This is often simpler and more flexible.
function movable (obj) {
return {
...obj,
move (dx, dy) {
this.x += dx
this.y += dy
console.log(`Moved to (${this.x}, ${this.y})`)
}
}
}
let ball = movable({ x: 0, y: 0, r: 10 })
ball.move(5, 3)
Here, we're giving an ordinary object new behaviour by combining it with a small mixin function. This is called composition - an alternative to inheritance that's widely used in modern JavaScript.
One advantage of composition is we can add multiple behaviours to an object in this way - we can make it both movable() and rotatable(), for example - let ball = rotatable(movable({ x: 0, y: 0, r: 10 })). We can compose from lots of mixins. Meanwhile in inheritance, a class cannot extend multiple classes, just one class.