Design Patterns in TypeScript: When and Why
\n\nImagine you are building a house. You can lay bricks haphazardly, and it probably won't collapse. But will it be comfortable to live in? Hardly. Software architecture is the same construction, only instead of bricks, you have classes and functions. Design patterns in TypeScript are proven blueprints for typical architectural tasks. But, like any tool, they need to be used wisely.
\n\nIn this article, we won't rehash the dry theory from the GoF (Gang of Four) books. Instead, we'll break down which TypeScript design patterns are actually needed in modern development, when they save a project, and when they only add headaches. Let's go!
\n\nWhy TypeScript Changes the Game?
\n\nBefore diving into patterns, let's remember TypeScript's main feature — static typing. In JavaScript, we often used patterns to compensate for the language's dynamic nature. For example, the Factory Method helped hide the complexity of creating objects. In TypeScript, this complexity is often solved simply with interfaces and generics.
\n\nBut that doesn't mean patterns are dead. On the contrary, TypeScript makes them more expressive and safer. The compiler checks that you have correctly implemented the structure and prevents you from making silly mistakes. This is where TypeScript unleashes the power of GoF patterns.
\n\nTop 5 Patterns You Will Use Most Often
\n\n1. Singleton — When One is Good, and Two are Unnecessary
\n\nWhen to use: You need exactly one instance of a class for the entire application. Classic examples are a logger, application configuration, or a database connection.
\n\nWhy in TypeScript: TypeScript allows you to make a Singleton thread-safe and type-safe. Without it, you risk creating dozens of objects that waste memory and confuse state.
\n\nCode example:
\n\nclass DatabaseConnection {\n private static instance: DatabaseConnection;\n private connectionString: string;\n\n private constructor(connectionString: string) {\n this.connectionString = connectionString;\n // Simulating connection\n console.log(`Connecting to ${connectionString}`);\n }\n\n static getInstance(connectionString: string): DatabaseConnection {\n if (!DatabaseConnection.instance) {\n DatabaseConnection.instance = new DatabaseConnection(connectionString);\n }\n return DatabaseConnection.instance;\n }\n\n query(sql: string): void {\n console.log(`Executing query: ${sql}`);\n }\n}\n\n// Usage\nconst db = DatabaseConnection.getInstance('postgres://localhost:5432/mydb');\ndb.query('SELECT * FROM users');\n\n// Attempting to create another connection returns the same instance\nconst db2 = DatabaseConnection.getInstance('postgres://localhost:5432/mydb');\nconsole.log(db === db2); // true\n\nAntipattern: Don't make a Singleton for the sake of a Singleton. If an object can exist in multiple instances with different states (e.g., different connections to different databases), then Singleton is evil.
\n\n2. Factory Method — Creating Objects Without Pain
\n\nWhen to use: You don't know in advance exactly which object needs to be created. Or the creation process is complex and requires logic.
\n\nWhy in TypeScript: The Factory Method works great with interfaces and generics. You get flexibility without losing types.
\n\nCode example:
\n\n// Abstract product\ninterface Notification {\n send(message: string): void;\n}\n\n// Concrete products\nclass EmailNotification implements Notification {\n send(message: string): void {\n console.log(`Sending email: ${message}`);\n }