Creational Patterns in TypeScript
How could you instantiate classes? Easy task, almost always. Creational patterns provide us with solutions to everyday situations where the creation of objects is more challenging.
Sometimes you are faced with constructing complex objects or need to build them in a specific way. In other situations, you have many related classes and want to instantiate one based on runtime conditions. You may even have problems guaranteeing the uniqueness of an object or, on the contrary, want several almost identical copies.
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
Part of a series about Design Patterns with TypeScript.
1๏ธโฃ 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 and are meant to share the same state in their life cycle.
In these cases, it is necessary to have a single instance even if you try to build it more times. This way, we avoid passing it through the dependency chain or having multiple instances with potentially different data or configurations.
// ! โ Bad example not using singleton
export class Logger {
entries: string[] = [];
constructor() {}
log(message: string) {
this.entries.push(message);
console.log(message);
}
}
export class Application {
logger = new Logger();
main() {
this.logger.log("Hello world!");
// ! ๐คข dependency hell, remember to pass the instance down the chain
const service = new Service(this.logger);
service.doSomething();
}
}
export class Service {
constructor(private logger: Logger) {}
doSomething() {
this.logger.log("Doing something...");
const repository = new Repository();
repository.save({ name: "" });
}
}
export class Repository {
// ! ๐ฑ another instance,
// potentially different from the one in Application
private logger: Logger = new Logger();
save(user: { name: string }) {
this.logger.log("Saving user..." + 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
export class Logger {
// ToDo:
// alternatively, make the constructor private
// and add a static method to get the instance
private static instance: Logger;
entries: string[] = [];
constructor() {
if (Logger.instance) {
// * return the existing instance
return Logger.instance;
}
// * real initialization only happens the first time
Logger.instance = this;
}
log(message: string) {
this.entries.push(message);
console.log(message);
}
}
export class Application {
logger = new Logger();
main() {
this.logger.log("Hello world!");
// * ๐ no need to pass the instance down the chain
const service = new Service();
service.doSomething();
}
}
export class Service {
// * ๐ no worries about dependencies
private logger: Logger = new Logger();
doSomething() {
this.logger.log("Doing something...");
const repository = new Repository();
repository.save({ name: "" });
}
}
export class Repository {
// * ๐ no new instance created
private logger: Logger = new Logger();
save(user: { name: string }) {
this.logger.log("Saving user..." + 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 of not using a prototype
export class Activity {
constructor(
public title: string,
public allowsChildren: boolean,
public price: number,
public date: Date) {}
}
const activity = new Activity("Diving", true, 100, new Date(2025, 2, 7));
// ! ๐ฑ creating a new instance, but a similar one is already created
// It is a painful and error-prone task
const activity2 = new Activity("Diving", true, 100, new Date(2026, 1, 8));
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
export class Activity {
constructor(
public title: string,
public allowsChildren: boolean,
public price: number,
public date: Date) {}
// * ๐ clone method to create a new instance with some changes
// ToDo: could have a more semantic name like `createEdition`
cloneEdition(date: Date): Activity {
// * ๐ create a new instance using the constructor
// return new Activity
// (this.title, this.allowsChildren, this.price, date);
// * ๐ or use Object.assign to clone the existing instance
// return Object.assign({}, this, { date });
// * ๐ or use the spread operator to clone the existing instance
return { ...this, date } as Activity;
}
}
const activity = new Activity("Diving", true, 100, new Date(2025, 2, 7));
// * ๐ no need to create a new instance,
// clone the existing one, and ensure the defaults are correct
const activity2 = activity.cloneEdition(new Date(2026, 1, 8));
๐ญ 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 implementing the same interface or extending from a base class. Those classes are the byproduct of applying 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
import { ConsoleWriter, DatabaseWriter, FileWriter, Logger, Writer }
from "./factory.dependencies";
class Application {
main() {
// ! ๐ฑ which implementation to use?
let writer: Writer;
// ! ๐คข the logic is exposed and...
// ...๐ฑ may have to be repeated in other places
switch (process.env.WRITER|| "console") {
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. This allows us to change the criteria to choose the instance 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
import { ConsoleWriter, DatabaseWriter, FileWriter, Logger, Writer }
from "./factory.dependencies";
const writersCatalog = [
{
id: "console",
instance: new ConsoleWriter(),
},
{
id: "file",
instance: new FileWriter(),
},
{
id: "database",
instance: new DatabaseWriter(),
},
];
class WriterFactory {
// * ๐ factory method encapsulates logic to create the right instance
static createWriter(): Writer {
const writer = writersCatalog.find((w) => w.id=== process.env.LOGGER);
return writer?.instance || new ConsoleWriter();
}
}
class Application {
main() {
// * ๐ consumer does not need to know the logic
const writer = WriterFactory.createWriter();
const logger = new Logger(writer);
logger.log("Hello world!");
}
}
๐ท๐ผ Builder
Simplifies, drives or standardizes the construction of complex objects.
The problem
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.
import
{DatabaseWriter, FileWriter, Formatter, JsonFormatter, LogEntry, Writer}
from "./builder.dependencies";
// ! โ Bad example of 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 DatabaseWriter) {
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 FileWriter()); // ! ๐ฑ
// throws "Need a formatter"
logger.setFormatter(new JsonFormatter());
logger.setWriter(new DatabaseWriter()); // ! ๐ฑ
// throws "Incompatible formatter for this writer"
logger.log({ message: "Hello world!" });
// ! ๐ฑ you must remember to call the methods in the right 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. Now, anyone can call this method and safely use the result.
// * โ
Builder solution
import {
ConsoleWriter,
DatabaseWriter,
FileWriter,
Formatter,
JsonFormatter,
LogEntry,
SimpleFormatter,
Writer,
} from "./builder.dependencies";
// * ๐ no need to change the legacy code, just don't call it directly
export class Logger {
// Just the same as above
}
// * The builder wrapper
export class LoggerBuilder {
// * ๐ ensures that you will not need to know too much about the logger
public static build(formatter: Formatter, writer: Writer): Logger {
// * ๐ detects incompatibility before the logger is created
const areIncompatibles =
formatter instanceof JsonFormatter
&& writer instanceof DatabaseWriter;
if (areIncompatibles) {
// * ๐ and throw specific an error
// throw "Incompatible formatter";
// * ๐ or you can just use a default configuration
formatter = new SimpleFormatter();
writer = new ConsoleWriter();
}
const logger = new Logger();
// * ๐ ensures correct order
logger.setFormatter(formatter);
logger.setWriter(writer);
return logger;
}
}
An 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.
// * โ
โ
Builder Director solution
// * ๐ Director is an abstraction on top of the Builder
// * Offers a pre-made Catalog without knowing the internals
export class LoggerDirector {
public static buildADefaultLogger(): Logger {
return LoggerBuilder.build(new SimpleFormatter(), new FileWriter());
}
public static buildAFancyLogger(): Logger {
return LoggerBuilder.build(new JsonFormatter(), new ConsoleWriter());
}
}
class Application {
main() {
// * ๐ ask the director to get the logger you want from its catalog
const logger = LoggerDirector.buildAFancyLogger();
// * use it and forget about inconsistencies
logger.log({ message: "Hello world!" });
}
}
๐ 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.
Creational patterns cheat sheet.
As a handy cheat sheet, I remind you of the main creational patterns and their use cases.
- 1๏ธโฃ 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