How to create instances of classes? Easy task, you may think, but it is not always. Creational patterns give us solutions to everyday situations where the creation of objects is not so easy.
Sometimes you are faced with constructing complex objects or need to build them in a specific way. In other situations, you have a bunch of related classes and want to instantiate one of them based on runtime conditions. You may even have problems guaranteeing the uniqueness of an object or, on the contrary, want several almost identical copies.
Picture C Dustin at Unsplash |
This article will show how to solve these situations with four creational patterns with examples in TypeScript. Let's dive in!
🎒 Prerequisites
To complete this tutorial, you will need the following:
A local development environment for TypeScript
Basic knowledge of Object-Oriented Programming
Singleton
Ensures that a class has only one instance around the application.
The problem
Some classes, like a Logger
, a DatabaseConnection
, or a Configuration
file, are widely used throughout the application. In these cases, it is convenient to have a single instance. This way, we avoid passing it down the dependency chain or having multiple instances with potentially different data or configurations.
// ❌ Bad example not using singleton
class Logger {
constructor(
private formatter: Formatter = DefaultFormatter,
private writer: Writer = DefaultWriter
) {}
log(message: string) {
this.writer.write(this.formatter.format(message));
}
}
class Application {
logger = new Logger(new Formatter(), new Writer());
main() {
this.logger.log("Hello world!");
// 🤢 dependency hell, remember to pass the instance down the chain
const service = new Service(this.logger);
service.doSomething();
}
}
class Service {
constructor(private logger: Logger) {}
doSomething() {
this.logger.log("Doing something...");
const repository = new Repository();
repository.save(new User());
}
}
class Repository {
// 😱 another instance, potentially different from the one in Application
private logger: Logger = new Logger(new Formatter(), new Writer());
save(user: User) {
this.logger.log("Saving user...");
// ...
}
}
The solution with the Singleton pattern
Using the singleton pattern, we can avoid this dependency hell and error-prone situations. It is a straightforward pattern, but it is handy.
// ✅ Singleton solution
class Logger {
private static instance: Logger;
private formatter: Formatter;
private writer: Writer;
constructor(
formatter: Formatter = DefaultFormatter,
writer: Writer = DefaultWriter
) {
if (Logger.instance) {
// return the existing instance
return Logger.instance;
}
// real initialization only happens the first time
this.formatter = formatter;
this.writer = writer;
Logger.instance = this;
}
log(message: string) {
this.writer.write(this.formatter.format(message));
}
}
class Application {
logger = new Logger(new Formatter(), new Writer());
main() {
this.logger.log("Hello world!");
// 😏 no need to pass the instance down the chain
const service = new Service();
service.doSomething();
}
}
class Service {
// 😏 no worries about different configurations
private logger: Logger = new Logger();
doSomething() {
this.logger.log("Doing something...");
const repository = new Repository();
repository.save({});
}
}
class Repository {
// 😏 no new instance created
private logger: Logger = new Logger(new Formatter(), new Writer());
save(user: any) {
this.logger.log("Saving user...");
// ...
}
}
Prototype
Creates a copy (clone) of an existing object with controlled changes (mutations)
The problem
Here we face the opposite situation. We want to create a copy of an object but change some of its properties. For example, have a new product with different sizes and prices. Or we are generating a cancellation record from an existing order.
// ❌ Bad example not using a prototype
class Product {
constructor(
public name: string,
public category: string,
public composition: string,
public size: string,
public price: number
) {}
}
const mediumTShirt = new Product("T-shirt", "Clothes", "100% cotton", "M", 10);
// 😱 creating a new instance but similar instance is a painfully and error-prone task
const largeTShirt = new Product("T-shirt", "Clothes", "100! cotton", "L", 12);
The solution with the Prototype pattern
The prototype pattern is an excellent solution for this problem. By cloning an existing object, we can create a new one with the desired changes while ensuring the defaults are correct.
// ✅ Prototype solution
class Product {
constructor(
public name: string,
public category: string,
public composition: string,
public size: string,
public price: number
) {}
// 😏 clone method to create a new instance with some changes
// could have a more semantic name like `withSizeAndPrice`
clone(newSize: string, newPrice: number) {
return new Product(
this.name,
this.category,
this.composition,
newSize,
newPrice
);
}
}
const mediumTShirt = new Product("T-shirt", "Clothes", "100% cotton", "M", 10);
// 😏 no need to create a new instance, just clone the existing one, and ensure the defaults are correct
const largeTShirt = mediumTShirt.clone("L", 12);
Factory
Creates instances of different classes that implement the same interface (or extend the same base class)
The problem
OOP techniques often end with several classes that implement the same interface or extend from a base class. Those classes are the byproduct of the Open/Closed Principle or the Interface Segregation Principle, both part of SOLID principles. The problem arises when we must choose which class to instantiate at runtime.
For example, we have a LoggerWriter
interface with different implementations like ConsoleLogger
, FileLogger
, and DatabaseLogger
. Or we have a User
base class with variations like Organizer
, Customer
, and Participant
.
// ❌ Bad example not using a factory
interface LoggerWriter {
write(message: string): void;
}
class ConsoleWriter implements LoggerWriter {
write(message: string) {
console.log(message);
}
}
class FileWriter implements LoggerWriter {
write(message: string) {
// ...
}
}
class DatabaseWriter implements LoggerWriter {
write(message: string) {
// ...
}
}
class Application {
main() {
// 😱 which implementation to use?
let writer: LoggerWriter;
// 😱 the logic is exposed, and may have to be repeated in other places
switch (process.env.LOGGER) {
case "console":
writer = new ConsoleWriter();
break;
case "file":
writer = new FileWriter();
break;
case "database":
writer = new DatabaseWriter();
break;
default:
throw new Error("Invalid logger");
}
const logger = new Logger(writer);
logger.log("Hello world!");
}
}
The solution with the Factory pattern
The Factory Method pattern (factory for short) solves this problem by encapsulating the logic to create the correct instance. It also allows us to change the criteria to choose the implementation without affecting the rest of the code.
Disclaimer: Yes, you should avoid using 🤮 switch statements, but at least having only one is better than having it in multiple places and encourages you to refactor it
// ✅ Factory solution
interface LoggerWriter {
write(message: string): void;
}
class ConsoleWriter implements LoggerWriter {
write(message: string) {
console.log(message);
}
}
class FileWriter implements LoggerWriter {
write(message: string) {
// ...
}
}
class DatabaseWriter implements LoggerWriter {
write(message: string) {
// ...
}
}
// 😏 factory method encapsulates the logic to create the correct instance
function createLoggerWriter(): LoggerWriter {
// 😏 if the criteria changes, we only need to change the factory
switch (process.env.LOGGER) {
case "console":
return new ConsoleWriter();
case "file":
return new FileWriter();
case "database":
return new DatabaseWriter();
default:
throw new Error("Invalid logger");
}
}
class Application {
main() {
const writer = createLoggerWriter();
const logger = new Logger(writer);
logger.log("Hello world!");
}
}
Builder
Simplifies, drives, or standardizes the construction of complex objects.
The problem
Ideally, we should design multiple simple classes with a small number of properties. Also, we should be able to use any method of the class right after construction. You can achieve this by asking for mandatory parameters as construction. However, sometimes we need to deal with complex or poorly designed objects that need some rituals before being usable.
For example, an Order
class comprises Customer
, Product
, Payment
, and Delivery
information. Or a Logger
class with a Formatter
and a Writer
that needs to be appropriately configured before being used.
// ❌ Bad example not using a builder
class Logger {
private formatter: Formatter | undefined;
private writer: Writer | undefined;
setFormatter(formatter: Formatter): void {
this.formatter = formatter;
}
setWriter(writer: Writer): void {
if (!this.formatter) {
throw "Need a formatter";
}
if (
this.formatter instanceof JsonFormatter &&
writer instanceof TextFileWriter
) {
throw "Incompatible formatter for this writer";
}
this.writer = writer;
}
log(entry: LogEntry) {
if (!this.writer || !this.formatter) {
throw new Error("Logger is not configured");
}
this.writer.write(this.formatter.format(entry));
}
}
class Application {
main() {
const logger = new Logger();
logger.setWriter(new TextFileWriter()); // 😱 throws "Need a formatter"
logger.setFormatter(new JsonFormatter());
logger.setWriter(new TextFileWriter()); // 😱 throws "Incompatible formatter for this writer"
logger.log({ message: "Hello world!" });
// 😱 you must remember to call the methods in the correct order,
// and do it every time you need a new instance
}
}
The solution with the Builder pattern
The Builder pattern solves this problem by creating a separate class that encapsulates the logic to construct the correct instance.
// ✅ Builder solution
class LoggerBuilder {
// 😏 ensures that the client will not need to know too much about the logger
public static build(formatter: Formatter, writer: Writer): Logger {
if (
formatter instanceof JsonFormatter &&
writer instanceof TextFileWriter
) {
// 😏 detects incompatibility before the logger is created
throw "Incompatible formatter";
}
const logger = new Logger();
// 😏 ensures correct order
logger.setFormatter(new JsonFormatter());
logger.setWriter(new ConsoleWriter());
return logger;
}
}
A even better solution with a director
Eventually, some compositions shine as more commonly used. For example, a Logger
with a JsonFormatter
and a ConsoleWriter
is a typical combo. In this case, we can create a LoggerDirector
with a catalog of those pre-made combinations.
// * 😏 director is an abstraction on top of the builder
// to give clients what they want without knowing the internals
class LoggerDirector {
public static buildADefaultLogger(): Logger {
return LoggerBuilder.build(new SimpleFormatter(), new TextFileWriter());
}
public static buildAFancyLogger(): Logger {
return LoggerBuilder.build(new JsonFormatter(), new ConsoleWriter());
}
}
🌅 Conclusion
When you go to instantiate objects, you will find yourself in certain situations repeatedly. There are proven solutions to those problems, and they are called creational patterns. In this article, I showed you their implementation in TypeScript. As a cheat sheet, I remind you of the patterns and their use cases.
Singleton: to create a single instance of a class.
Prototype : to create a modified copy of an existing object.
Factory : to create objects of related types.
Builder : to create a complex object.
I hope you enjoyed it and learned something new. If you have any questions or suggestions, please leave a comment below.
learn, code, enjoy, repeat
Alberto Basalo