Programming is problem-solving. Some problems in everyday work are repeated from project to project. Such problems typically already have solutions — these solutions are called patterns or design templates.
Creational patterns (or templates) help solve problems related to creating entities or groups of similar entities. They eliminate code duplication and make the object creation process shorter and more straightforward.
We will explore the 4 most common patterns:
- Factory
- Abstract Factory
- Builder
- Singleton
Factory
A factory (Eng. factory) creates an object, freeing us from the need to know the details of creation.
For example, if we need a guitar, we can cut the body, make the strings out of nickel, glue the body, make the neck, place the frets, and tighten the strings... Or we can go to the store and get a guitar made in the factory — in this case, we no longer need to know what exactly needs to be done to create the guitar.
Example
A factory in programming accepts a signal from us that we need to create an object and creates it by encapsulating the creation logic within itself.
function createGuitar(stringsCount = 6) { return { strings: stringsCount, frets: 24, fretBoardMaterial: 'cedar', boardMaterial: 'maple', }}
function createGuitar(stringsCount = 6) { return { strings: stringsCount, frets: 24, fretBoardMaterial: 'cedar', boardMaterial: 'maple', } }
In the example, we return a guitar object from the factory function create
. The function accepts the number of strings as an argument and uses it as the value for the strings
field. It fills in all other fields by itself.
In this example, we don't need to know exactly how the field describing the number of strings should be named; we only pass it as an argument. We will get the guitar object like this:
const sixStringsGuitar = createGuitar(6)const sevenStringsGuitar = createGuitar(7)
const sixStringsGuitar = createGuitar(6) const sevenStringsGuitar = createGuitar(7)
The advantage of the factory is that knowledge about how to create an object is in one place — inside the factory. If the schema (interface) of the object changes, we only need to change the code in one place — in the factory.
Suppose we now need to:
- Change the name of the
frets
field tofrets
.Count - Change the name of the
strings
field tostrings
.Count - Set the default to 7 strings.
- Change the neck material to fir.
function createGuitar(stringsCount = 7) { return { stringsCount, fretsCount: 24, fretBoardMaterial: 'fir', boardMaterial: 'maple', };}
function createGuitar(stringsCount = 7) { return { stringsCount, fretsCount: 24, fretBoardMaterial: 'fir', boardMaterial: 'maple', }; }
The places where we actually create objects, that is, call the factory, remain unchanged:
const sixStringsGuitar = createGuitar(6)const sevenStringsGuitar = createGuitar(7)
const sixStringsGuitar = createGuitar(6) const sevenStringsGuitar = createGuitar(7)
We are also protected from situations where we need to return instances of a class instead of a simple object:
function createGuitar(stringsCount = 6) { return new Guitar({ strings: stringsCount, frets: 24, fretBoardMaterial: 'fir', boardMaterial: 'maple', })}
function createGuitar(stringsCount = 6) { return new Guitar({ strings: stringsCount, frets: 24, fretBoardMaterial: 'fir', boardMaterial: 'maple', }) }
The rest of the code remains the same as it was before.
When to Use
Use a factory if creating an object is more complex than 1-2 lines of code.
This template is especially useful when creating an object requires calculations or obtaining additional data:
function createGuitar(strings = 6, maxWeight = 5) { const fretBoardMaterial = maxWeight <= 5 ? 'fir' : 'cedar' return { strings, frets: 24, fretBoardMaterial, boardMaterial: 'maple', }}
function createGuitar(strings = 6, maxWeight = 5) { const fretBoardMaterial = maxWeight <= 5 ? 'fir' : 'cedar' return { strings, frets: 24, fretBoardMaterial, boardMaterial: 'maple', } }
In the example above, we choose the neck material based on the maximum allowed weight. When creating an object requires some logic, it's better to encapsulate it in the factory right away than to repeat the code in different places in the codebase.
Abstract Factory
An abstract factory (Eng. abstract factory) — is a factory of factories 😃
This pattern groups related or similar factories of objects together, allowing you to choose the one needed depending on the situation.
An abstract factory does not return a specific object; instead, it describes the type of object that will be created.
Example
The concept of an abstract factory is easiest to explain using TypeScript and the notion of an interface. Suppose we have an application that manages the musical inventory for a concert orchestra.
The instruments are different, but we can describe them all using the Instrument
interface:
interface Instrument { playNote(note: MusicNote): void;}
interface Instrument { playNote(note: MusicNote): void; }
We can then describe a violin or a cello like this:
class Violin implements Instrument { playNote(note) { console.log(`Playing ${note} on the violin!`); }}class Cello implements Instrument { playNote(note) { console.log(`Playing ${note} on the cello!`); }}
class Violin implements Instrument { playNote(note) { console.log(`Playing ${note} on the violin!`); } } class Cello implements Instrument { playNote(note) { console.log(`Playing ${note} on the cello!`); } }
Orchestra musicians strictly play on their instruments, but we can describe all musicians using the Musician
interface:
interface Musician { play(piece: MusicPiece): void;}
interface Musician { play(piece: MusicPiece): void; }
Then, for example, we can represent violinists and cellists like this:
class Violinist implements Musician { private instrument: Instrument = new Violin() play = (piece) => piece.forEach((note) => this.instrument.playNote(note)) // Playing A# on the violin! // Playing C on the violin! // ...}class Cellist implements Musician { private instrument: Instrument = new Cello() play = (piece) => piece.forEach((note) => this.instrument.playNote(note)) // Playing A# on the cello! // Playing C on the cello! // ...}
class Violinist implements Musician { private instrument: Instrument = new Violin() play = (piece) => piece.forEach((note) => this.instrument.playNote(note)) // Playing A# on the violin! // Playing C on the violin! // ... } class Cellist implements Musician { private instrument: Instrument = new Cello() play = (piece) => piece.forEach((note) => this.instrument.playNote(note)) // Playing A# on the cello! // Playing C on the cello! // ... }
Now let's write part of the application that reserves seats and instruments for each musician in the orchestra.
class ViolinReservation { reserveViolin = () => new Violin() notifyPlayer = () => new Violinist()}class CelloReservation { reserveCello = () => new Cello() notifyPlayer = () => new Cellist()}
class ViolinReservation { reserveViolin = () => new Violin() notifyPlayer = () => new Violinist() } class CelloReservation { reserveCello = () => new Cello() notifyPlayer = () => new Cellist() }
Let the seats be reserved by a function reserve
. A problem arises when we want to use a common function with different classes to reserve seats. It is unclear what type the argument should be, and it is also unclear which method to call to reserve the instrument:
// In the argument, we can use a union of types,// but if another class is added,// we'll have to update this union too :–(function reserve(reservation: ViolinReservation | CelloReservation): void { // Notify the musician, we can do: reservation.notifyPlayer() // But for calling the method to reserve the instrument, // we need to know which class we have :–( if (reservation instanceof ViolinReservation) { reservation.reserveViolin() } else if (reservation instanceof CelloReservation) { reservation.reserveCello() }}
// In the argument, we can use a union of types, // but if another class is added, // we'll have to update this union too :–( function reserve(reservation: ViolinReservation | CelloReservation): void { // Notify the musician, we can do: reservation.notifyPlayer() // But for calling the method to reserve the instrument, // we need to know which class we have :–( if (reservation instanceof ViolinReservation) { reservation.reserveViolin() } else if (reservation instanceof CelloReservation) { reservation.reserveCello() } }
Such a function will be very fragile and will require updating when the composition of the orchestra changes. We want to create one interface for reserving any instruments. This interface will ensure that no matter how many musicians there are or what they are, we can always call one method for reservation.
The abstract factory is just the solution for this problem:
// Common interface:interface ReservationFactory { reserveInstrument(): Instrument; notifyPlayer(): Musician;}// Implementations for different instruments:class ViolinReservation implements ReservationFactory { reserveInstrument = () => new Violin() notifyPlayer = () => new Violinist()}class CelloReservation implements ReservationFactory { reserveInstrument = () => new Cello() notifyPlayer = () => new Cellist()}
// Common interface: interface ReservationFactory { reserveInstrument(): Instrument; notifyPlayer(): Musician; } // Implementations for different instruments: class ViolinReservation implements ReservationFactory { reserveInstrument = () => new Violin() notifyPlayer = () => new Violinist() } class CelloReservation implements ReservationFactory { reserveInstrument = () => new Cello() notifyPlayer = () => new Cellist() }
Then the reserve
function becomes more straightforward and less fragile:
function reserve(reservation: ReservationFactory): void { reservation.notifyPlayer() reservation.reserveInstrument()}
function reserve(reservation: ReservationFactory): void { reservation.notifyPlayer() reservation.reserveInstrument() }
Since the interface remains the same, we can use it when working with any instruments. Thus, we move away from creating concrete objects, replacing them with an abstraction — their type or interface.
When to Use
If there is a common logic for creating related or similar but not identical objects in the application, the abstract factory helps eliminate duplication and encapsulate creation rules within itself.
Builder
Builder (or constructor) (Eng. builder) allows creating objects by adding properties to them by specified rules. It is useful when creating an object requires many steps, some of which may be optional.
Example
Suppose we are writing a coffee drink constructor. All of them are based on espresso, but there can be many additional ingredients.
class Drink { constructor(settings) { const { base, milk, sugar, cream } = settings this.base = base this.milk = milk this.sugar = sugar this.cream = cream }}
class Drink { constructor(settings) { const { base, milk, sugar, cream } = settings this.base = base this.milk = milk this.sugar = sugar this.cream = cream } }
We can add milk, sugar, and cream.
To make it convenient to create drink objects, we will tell the builder step by step what to add to the coffee:
class DrinkBuilder { settings = { base: 'espresso', } addMilk = () => { this.settings.milk = true return this } addSugar = () => { this.settings.sugar = true return this } addCream = () => { this.settings.cream = true return this } addSyrup = () => { this.settings.syrup = true return this } build = () => new Drink(this.settings)}
class DrinkBuilder { settings = { base: 'espresso', } addMilk = () => { this.settings.milk = true return this } addSugar = () => { this.settings.sugar = true return this } addCream = () => { this.settings.cream = true return this } addSyrup = () => { this.settings.syrup = true return this } build = () => new Drink(this.settings) }
By default, we add only espresso to the settings, but when calling add
methods, we add a new ingredient to the settings. On calling build
, we return the assembled drink:
const latte = new DrinkBuilder().addMilk().build()const withSugarAndCream = new DrinkBuilder().addSugar().addCream().build()
const latte = new DrinkBuilder().addMilk().build() const withSugarAndCream = new DrinkBuilder().addSugar().addCream().build()
Note that we can chain the add
methods, ending with the build
call. This is possible because each of the add
methods returns the current instance of the builder.
// ...addMilk = () => { this.settings.milk = true // Return the current builder: return this};
// ... addMilk = () => { this.settings.milk = true // Return the current builder: return this };
Thus, we use useful functionality — we add an ingredient to the settings and then return this
— referring to ourselves to apply a new method of this class.
When to Use
If the application requires creating objects with different features, or the process of creating an object is divided into separate steps, then the builder helps avoid cluttering the code with conditions and checks.
Singleton
A singleton (Eng. singleton) is a pattern that allows creating only one object, and upon attempting to create a new one, it returns the already created one.
Example
Suppose we are writing an application to describe the Solar System. There can be only one Sun, so we can create it only once.
If there is code in the application that tries to create the Sun again for some reason, our class will return the existing object instead of creating another one.
class Sun { // Store a reference to the created object: static #instance = null // Make the constructor private: constructor() { // If the object was created earlier, return it: if (Sun.#instance) { return Sun.#instance } // Otherwise, assign the current value of this to the object: Sun.#instance = this }}
class Sun { // Store a reference to the created object: static #instance = null // Make the constructor private: constructor() { // If the object was created earlier, return it: if (Sun.#instance) { return Sun.#instance } // Otherwise, assign the current value of this to the object: Sun.#instance = this } }
We will use such a singleton like this:
// On the first call, a new object will be created:const sun = new Sun()// In the future, the instance will return// the previously created object:const sun1 = new Sun()const sun2 = new Sun()console.log(sun === sun1)// trueconsole.log(sun === sun2)// true
// On the first call, a new object will be created: const sun = new Sun() // In the future, the instance will return // the previously created object: const sun1 = new Sun() const sun2 = new Sun() console.log(sun === sun1) // true console.log(sun === sun2) // true
When to Use
When you need to ensure strictly one instance of an object for the entire application. This is often unnecessary.
Before using a singleton, consider redesigning the program so that there is no need to use a singleton.
Do Not Confuse With
Singleton, as a pattern, and singleton, as a type of lifecycle for objects in dependency injection — are different things.
The first restricts the number of objects, the second determines which objects and how they will be injected as dependencies into other objects. You can read more about dependency injection in the article “Dependency Injection with TypeScript in Practice”.
Other Patterns
We have explored the most common of the creational design patterns. There are a few more, but the rest are used less frequently.
In addition to creational, there are also other types of design patterns:
- Structural — help solve problems related to how to combine and blend entities together.
- Behavioral — distribute responsibilities among modules and determine how communication will take place.