Home >Web Front-end >JS Tutorial >Mastering the Builder Pattern: Create a Dynamic AI Prompt Generator CLI
Have you ever encountered a case in your development journey where you had to deal with complex objects? Maybe because they either have too many parameters, which can even be nested, or require many building steps and complex logic to be constructed.
Perhaps you want to design a module with a clean and easy interface without having to scatter or think about the creation code of your complex objects every time!
That's where the builder design pattern comes in!
Throughout this tutorial, we will be explaining everything about the builder design pattern, then we will build a CLI Node.js application for generating a DALL-E 3 optimized image generation prompt using the builder design pattern.
The final code is available in this Github Repository.
Builder is a creational design pattern , which is a category of design patterns that deals with the different problems that come with the native way of creating objects with the new keyword or operator.
The Builder Design Pattern focuses on solving the following problems:
Providing an easy interface to create complex objects : Imagine a deeply nested object with many required initialization steps.
Separating the construction code from the object itself , allowing for the creation of multiple representations or configurations out of the same object.
The Builder Design Pattern solves these two problems by delegating the responsibility of object creation to special objects called builders.
The builder object composes the original object and breaks down the creation process into multiple stages or steps.
Each step is defined by a method in the builder object which initializes a subset of the object attributes based on some business logic.
class PromptBuilder { private prompt: Prompt constructor() { this.reset() } reset() { this.prompt = new Prompt() } buildStep1() { this.prompt.subject = "A cheese eating a burger" //initialization code... return this } buildStep2() { //initialization code... return this } buildStep3() { //initialization code... return this } build() { const result = structuredClone(this.prompt) // deep clone this.reset() return result } }
Client code: we just need to use the builder and call the individual steps
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder .buildStep1() // optional .buildStep2() // optional .buildStep3() // optional .build() // we've got a prompt const prompt2 = promptBuilder .buildStep1() // optional .buildStep3() // optional .build() // we've got a prompt
The typical builder design pattern consists of 4 main classes:
Builder : The builder interface should only define the construction methods without the build() method, which is responsible for returning the created entity.
Concrete Builder Classes : Each concrete Builder provides its own implementation of the Builder Interface methods so that it can produce its own variant of the object (instance of Product1 or Product2 ).
Client : You can think of the client as the top-level consumer of our objects, the user who is importing library modules or the entry point of our application.
Director : Even the same builder object can produce many variants of the object.
class PromptBuilder { private prompt: Prompt constructor() { this.reset() } reset() { this.prompt = new Prompt() } buildStep1() { this.prompt.subject = "A cheese eating a burger" //initialization code... return this } buildStep2() { //initialization code... return this } buildStep3() { //initialization code... return this } build() { const result = structuredClone(this.prompt) // deep clone this.reset() return result } }
As you can see from the code above, there is a big need for some entity to take the responsibility of directing or orchestrating the different possible combination sequences of calls to the builder methods, as each sequence may produce a different resulting object.
So can we further abstract the process and provide an even simpler interface for the client code?
That's where the Director class comes in. The director takes more responsibilities from the client and allows us to factor all of those builder sequence calls and reuse them as needed.
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder .buildStep1() // optional .buildStep2() // optional .buildStep3() // optional .build() // we've got a prompt const prompt2 = promptBuilder .buildStep1() // optional .buildStep3() // optional .build() // we've got a prompt
Client code
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder.buildStep1().buildStep2().build() const prompt2 = promptBuilder.buildStep1().buildStep3().build()
As you can see from the code above, the client code doesn't need to know about the details for creating prompt1 or prompt2. It just calls the director, sets the correct builder object, and then calls the makePrompt methods.
To further demonstrate the builder design pattern's usefulness, let's build a prompt engineering image generation AI CLI tool from scratch.
The source code for this CLI app is available here.
The CLI tool will work as follows:
The realistic prompt will need all of the following configuration attributes to be constructed.
file: prompts.ts
class Director { private builder: PromptBuilder constructor() {} setBuilder(builder: PromptBuilder) { this.builder = builder } makePrompt1() { return this.builder.buildStep1().buildStep2().build() } makePrompt2() { return this.builder.buildStep1().buildStep3().build() } }
file: prompts.ts
const director = new Director() const builder = new PromptBuilder() director.setBuilder(builder) const prompt1 = director.makePrompt1() const prompt2 = director.makePrompt2()
As you can see here, each prompt type requires many complex attributes to be constructed, like artStyle , colorPalette , lightingEffect , perspective , cameraType , etc.
Feel free to explore all of the attribute details, which are defined in the enums.ts file of our project.
enums.ts
class PromptBuilder { private prompt: Prompt constructor() { this.reset() } reset() { this.prompt = new Prompt() } buildStep1() { this.prompt.subject = "A cheese eating a burger" //initialization code... return this } buildStep2() { //initialization code... return this } buildStep3() { //initialization code... return this } build() { const result = structuredClone(this.prompt) // deep clone this.reset() return result } }
The user of our CLI app may not be aware of all these configurations; they may just want to generate an image based on a specific subject like cheese eating burger and style (Realistic or Digital Art).
After cloning the Github repository, install the dependencies using the following command:
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder .buildStep1() // optional .buildStep2() // optional .buildStep3() // optional .build() // we've got a prompt const prompt2 = promptBuilder .buildStep1() // optional .buildStep3() // optional .build() // we've got a prompt
After installing the dependencies, run the following command:
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder.buildStep1().buildStep2().build() const prompt2 = promptBuilder.buildStep1().buildStep3().build()
You'll be prompted to choose a prompt type: Realistic or Digital Art.
Then you will have to enter the subject of your prompt. Let's stick with cheese eating burger.
Depending on your choice, you will get the following text prompts as a result:
Realistic Style Prompt :
class Director { private builder: PromptBuilder constructor() {} setBuilder(builder: PromptBuilder) { this.builder = builder } makePrompt1() { return this.builder.buildStep1().buildStep2().build() } makePrompt2() { return this.builder.buildStep1().buildStep3().build() } }
Digital Art Style Prompt :
const director = new Director() const builder = new PromptBuilder() director.setBuilder(builder) const prompt1 = director.makePrompt1() const prompt2 = director.makePrompt2()
Copy the previous commands and then paste them into ChatGPT. ChatGPT will use the DALL-E 3 model to generate the images.
Realistic Image Prompt Result
Digital Art Image Prompt Result
Remember the prompt parameters' complexity and the expertise needed to construct each type of prompt, not to mention the ugly constructor calls which are needed.
class RealisticPhotoPrompt { constructor( public subject: string, public location: string, public timeOfDay: string, public weather: string, public camera: CameraType, public lens: LensType, public focalLength: number, public aperture: string, public iso: number, public shutterSpeed: string, public lighting: LightingCondition, public composition: CompositionRule, public perspective: string, public foregroundElements: string[], public backgroundElements: string[], public colorScheme: ColorScheme, public resolution: ImageResolution, public postProcessing: string[] ) {} }
Disclaimer: This ugly constructor call is not a big issue in JavaScript because we can pass a configuration object with all the properties being nullable.
To abstract the process of building the prompt and make our code open for extension and closed for modification (O in SOLID), and to make using our prompt generation library seamless or easier for our library clients, we will be opting to implement the builder design pattern.
Let's start by declaring the generic prompt builder interface.
The interface declares a bunch of methods:
builders.ts
class PromptBuilder { private prompt: Prompt constructor() { this.reset() } reset() { this.prompt = new Prompt() } buildStep1() { this.prompt.subject = "A cheese eating a burger" //initialization code... return this } buildStep2() { //initialization code... return this } buildStep3() { //initialization code... return this } build() { const result = structuredClone(this.prompt) // deep clone this.reset() return result } }
builders.ts
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder .buildStep1() // optional .buildStep2() // optional .buildStep3() // optional .build() // we've got a prompt const prompt2 = promptBuilder .buildStep1() // optional .buildStep3() // optional .build() // we've got a prompt
As you can see from the implementations above, each builder chooses to build its own kind of prompt (the final prompt shapes are different) while sticking to the same building steps defined by the PromptBuilder contract!
Now, let's move on to our Director class definition.
director.ts
const promptBuilder = new PromptBuilder() const prompt1 = promptBuilder.buildStep1().buildStep2().build() const prompt2 = promptBuilder.buildStep1().buildStep3().build()
The Director class wraps a PromptBuilder and allows us to create a prompt configuration which consists of calling all the builder methods starting from setSubject to buildArtisticElements.
This will simplify our client code in the index.ts file, which we will see in the next section.
serializers.ts
class Director { private builder: PromptBuilder constructor() {} setBuilder(builder: PromptBuilder) { this.builder = builder } makePrompt1() { return this.builder.buildStep1().buildStep2().build() } makePrompt2() { return this.builder.buildStep1().buildStep3().build() } }
To print the final prompt text to the terminal console, I've implemented some utility serialization functions.
Now our prompt library generation code is ready. Let's make use of it in the index.ts file.
index.ts
const director = new Director() const builder = new PromptBuilder() director.setBuilder(builder) const prompt1 = director.makePrompt1() const prompt2 = director.makePrompt2()
The code above performs the following actions:
Remember: it's not possible to get the prompt from the director because the shape of the prompt produced by each builder type is different.
The Builder design pattern proves to be an excellent solution for creating complex objects with multiple configurations, as demonstrated in our AI image prompt generation CLI application. Here's why the Builder pattern was beneficial in this scenario:
Simplified Object Creation : The pattern allowed us to create intricate RealisticPhotoPrompt and DigitalArtPrompt objects without exposing their complex construction process to the client code.
Flexibility : By using separate builder classes for each prompt type, we could easily add new prompt types or modify existing ones without changing the client code.
Code Organization : The pattern helped separate the construction logic from the representation, making the code more modular and easier to maintain.
Reusability : The PromptDirector class allowed us to reuse the same construction process for different types of prompts, enhancing code reusability.
Abstraction : The client code in index.ts remained simple and focused on high-level logic, while the complexities of prompt construction were abstracted away in the builder classes.
If you have any questions or want to discuss something further, feel free to Contact me here.
Happy coding!
The above is the detailed content of Mastering the Builder Pattern: Create a Dynamic AI Prompt Generator CLI. For more information, please follow other related articles on the PHP Chinese website!