This is a beginning of a series that targets code design in Typescript, your insights are invaluable.
The following blog is influenced by refactoring guru
The Problem
You have a schema bloated with optional attributes, how do mange constructing it?
// Schema
class House {
public windows?: number;
public doors?: number;
public rooms?: number;
public garage?: boolean;
public walls?: boolean;
public swimmingPool?: boolean;
public garden?: boolean;
public fence?: boolean;
constructor(data: {
windows?: number;
doors?: number;
rooms?: number;
garage?: boolean;
walls?: boolean;
swimmingPool?: boolean;
garden?: boolean;
fence?: boolean;
}) {
this.windows = data.windows;
this.doors = data.doors;
this.rooms = data.rooms;
this.garage = data.garage;
this.walls = data.walls;
this.swimmingPool = data.swimmingPool;
this.garden = data.garden;
this.fence = data.fence;
}
addWindows = (windows: number) => {
this.windows = this.windows ? windows + this.windows : windows;
};}
// Constructing Schema
const house = new House({
doors: 4,
windows: 4,
rooms: 4,
garage: true,
walls: true,
garden: true,
fence: true,
});
As you can see from the above example, each time you create instance of the class you will need to specify every needed attribute, which can be tedious, also error prone.
The Solution
Builder Pattern
This pattern achieves the ability to construct objections by using steps, the pattern allows you to produce different types and representations of an object using the same construction code.
Steps:
1. Create an interface for our class
abstract class House {
public abstract windows: number;
public abstract doors: number;
public abstract rooms: number;
public abstract garage: boolean;
public abstract walls: boolean;
public abstract swimmingPool: boolean;
public abstract garden: boolean;
public abstract fence: boolean;
}
class HouseA extends House {
public windows!: number;
public doors!: number;
public rooms!: number;
public garage!: boolean;
public walls!: boolean;
public swimmingPool!: boolean;
public garden!: boolean;
public fence!: boolean;
addWindows = (windows: number) => (this.windows += windows);
}
class HouseB implements House {
constructor(
public windows: number,
public doors: number,
public rooms: number,
public garage: boolean,
public walls: boolean,
public swimmingPool: boolean,
public garden: boolean,
public fence: boolean
) {}
addWindows = (windows: number) => (this.windows += this.rooms * windows);
}
Remember Dependency inversion principal that High-level classes shouldn’t depend on low-level classes. Both should depend on abstractions. Abstractions shouldn’t depend on details. Details should depend on abstractions.
2. Create Builder interface and concrete implementation
abstract class HouseBuilder {
protected house!: House;
public abstract buildWalls: () => void;
public abstract buildDoors: () => void;
public abstract buildWindows: () => void;
public abstract buildGarage: () => void;
public abstract buildRooms: () => void;
public abstract buildSwimmingPool: () => void;
public abstract buildGarden: () => void;
public abstract buildFence: () => void;
public abstract reset: () => void;
public abstract getResults: () => House;
}
class ConcreteHouseBuilderA extends HouseBuilder {
house!: HouseA;
reset = () => {
this.house = new HouseA();
};
buildDoors = () => {
this.house.doors = 2;
};
buildWindows = () => {
this.house.windows = 2;
};
// You can continue the rest of the building methods
}
What did we do here?
- Created interface for
HouseBuilder
in case we need to create different concrete implementations for eachHouse
concrete class - We initialized inside the concrete the instance of
HouseA
meanwhile the interface was reliant onHouse
this will help us in creating more concrete builders - The main intent of builder class is to construct
HouseA
via steps
3. Create Director class
This is optional method, which wraps the builder implementation, this can help if we need to run builder steps in specific order, building walls before windows, etc.
Code:
type HouseType = "simple" | "luxury" | "modern";
class Director {
constructor(private builder: HouseBuilder) {}
changeBuilder = (builder: HouseBuilder) => (this.builder = builder);
reset = () => {
this.builder.reset();
};
buildBasicHouse = () => {
this.builder.buildDoors();
this.builder.buildWindows();
this.builder.buildRooms();
};
make = (type: HouseType) => {
this.builder.reset();
switch (type) {
case "simple":
this.builder.buildGarage();
break;
case "luxury":
this.builder.buildGarage();
this.builder.buildGarden();
this.builder.buildFence();
break;
case "modern":
this.builder.buildGarage();
this.builder.buildGarden();
this.builder.buildSwimmingPool();
this.builder.buildFence();
break;
default:
break;
}
};
}
Note that Director is reliant on Builder interface
Now implementation time:
const concreteHouseA = new ConcreteHouseBuilderA();
const director = new Director(concreteHouseA);
director.make("modern");
console.log(concrete1.getResults());
Conclusion
To sum up, using the Builder Pattern provides a simplified way to deal with the complexity of creating objects that have telescopic constructors. This design makes the construction process easier to interpret, more flexible, and easier to maintain by breaking it up into discrete parts. Code that is modular and readily extensible is encouraged by the use of interfaces and concrete implementations, which follow the Dependency Inversion Principle. Moreover, the optional Director class adds another level of structure by allowing building stages to be executed one after the other and making it easier to create various object configurations. Adopting such design principles increases overall project scalability, developer productivity, and code quality. We hope to learn more about code design techniques in this course, utilizing TypeScript's features to create beautiful and effective solutions. Watch this space for more insights and practical examples to elevate your coding proficiency.
Top comments (0)