Have you ever imported an object from a library and tried to clone it, only to fail because cloning it requires extensive knowledge of the library’s internals?
Or perhaps, after working on a project for a long time, you took a break to refactor your code and noticed that you’re recloning many complex objects in various parts of your codebase?
Well, the prototype design pattern has got you covered!
In this article, we will explore the prototype design pattern while building a fully functional journaling templates Node.js CLI application.
Without further ado, let’s dive into it!
Overview
Prototype 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.
Problem
The factory design pattern solves the following creational problems:
How can you copy an existing object in your application without depending on its concret classes?
Some complex objects are hard to clone, because they either have a lot of fields which need a particular busines logic that is either not know by you or or has a lot of private fields which are not accessible from outside of the objects.
Let's take as example the socket object imported from the socket.io library , imaging having to clone that yourself?
You will have to go through it's code inside the library, understand how sockets work, the objects has even some circular dependencies which you have to deal with your self in order to clone it.
In addition to that, Your code will depend on the socket class or interface and the corresponding business logic to create it, which violates the solid dependency inversion principle and makes your code less robust for changes.
Solution
The prototype design pattern solves these problems, by delegating the responsiblitiy of copying the object into the object itself, by declaring a clone method in every object's class which is meant to be clonable.
class Socket {
// code........
clone(): Socket {
// business logic to instantiate the socket.
return new Socket(/*...Params*/)
}
}
const socket1 = new Socket()
const socket2 = socket1.clone()
Structure
To implement the prototype design pattern you can either directly include the clone method inside the clonnable object.
Or create a common interface Prototype which can be implemented by all of the clonnable objects.
One benifit of having a common interface is the ability to register all the prototypes, in a common registery service class, which will be responsible on cahing the frequently used prototypes and return them to the user. Instead of having to clone the objects everytime the clone method gets called.
That can be really handy especially when cloning complex objects.
Practical Scenario
In this section, we're going to demo this design pattern by building a mini journaling templates Nodejs CLI application.
As we saw earlier the prototype design pattern delegates the responsiblity of cloning the object into the object itself.
But have you wondered why it's even called prototype?I mean what has that to do with cloning?
We will be answering that through this practical example, keep reading and stay tuned.
You can find the final code in this repository. Just clone it and run the following commands.
Creating our Prototype: Journaling Template Class
First let's create a JournalTemplate which has the following attributes:
- name : We will need it to identity the template.
- sections : A section is a portion of the journaling template which is reserved to a specific theme or topic such as: Gratitude, Challenges, Tomorrow's Goals....
Each section is consist of the following attributes:
- title The topic or theme of the section: Gratitude, Challenges, Tomorrow's Goals...
- prompt The message which will be displayed to the user when he is about to write the section journaling text.
JournalTemplate.ts
import chalk from "chalk"
import { TemplateSection } from "./types"
export interface TemplateSection {
title: string
prompt: string
}
export class JournalTemplate {
constructor(
public name: string,
public sections: TemplateSection[]
) {}
clone(): JournalTemplate {
return new JournalTemplate(
this.name,
this.sections.map((s) => ({ ...s }))
)
}
display(): void {
console.log(chalk.cyan(`\nTemplate: ${this.name}`))
this.sections.forEach((section, index) => {
console.log(chalk.yellow(`${index + 1}. ${section.title}`))
console.log(chalk.gray(` Prompt: ${section.prompt}`))
})
}
addSection(section: TemplateSection): void {
this.sections.push(section)
}
removeSection(index: number): void {
if (index >= 0 && index < this.sections.length) {
this.sections.splice(index, 1)
} else {
throw new Error("Invalid section index")
}
}
editSection(index: number, newSection: TemplateSection): void {
if (index >= 0 && index < this.sections.length) {
this.sections[index] = newSection
} else {
throw new Error("Invalid section index")
}
}
getSectionCount(): number {
return this.sections.length
}
getSection(index: number): TemplateSection | undefined {
return this.sections[index]
}
setName(newName: string): void {
this.name = newName
}
}
The JournalTemplate class has many utility methods for setting its diffirent attributes.
The display method will be used later to display a colored well formated output to the terminal.
the chalk package is used to color some pieced for the outputed terminal text.
Our JournalTemplate objects are meant to be used as the name implies as templates or prototypes for creating other templates or journaling file entries.
That's why we've added the clone method to the JournalTemplate class.
We've added it to give the responsibility of handling the cloning business logic to the JournalTemplate object itself rather than the consuming code.
Declaring Journaling Template Registery
Now let's create our TemplateRegistry class, which will be responsible on storing the JournalTemplate class prototype instances. While providing methods for manipulating those instances.
TemplateRegistry.ts
import { JournalTemplate } from "./JournalTemplate"
export class TemplateRegistry {
private templates: Map<string, JournalTemplate> = new Map()
addTemplate(name: string, template: JournalTemplate): void {
this.templates.set(name, template)
}
getTemplate(name: string): JournalTemplate | undefined {
const template = this.templates.get(name)
return template ? template.clone() : undefined
}
getTemplateNames(): string[] {
return Array.from(this.templates.keys())
}
}
The registery stores those classes in a Map object, for fast retreival by name, and exposes many utility methods for adding or retireiving templates instances.
Instantiating The Journaling Template Registery
Now, Let's instantiate the template registery and then seed some initial templates.
registry.ts
import { JournalTemplate } from "./JournalTemplate"
import { TemplateRegistry } from "./TemplateRegistry"
export const registry = new TemplateRegistry()
registry.addTemplate(
"Daily Reflection",
new JournalTemplate("Daily Reflection", [
{
title: "Gratitude",
prompt: "List three things you're grateful for today.",
},
{ title: "Accomplishments", prompt: "What did you accomplish today?" },
{
title: "Challenges",
prompt: "What challenges did you face and how did you overcome them?",
},
{
title: "Tomorrow's Goals",
prompt: "What are your top 3 priorities for tomorrow?",
},
])
)
registry.addTemplate(
"Weekly Review",
new JournalTemplate("Weekly Review", [
{ title: "Highlights", prompt: "What were the highlights of your week?" },
{
title: "Lessons Learned",
prompt: "What important lessons did you learn this week?",
},
{
title: "Progress on Goals",
prompt: "How did you progress towards your goals this week?",
},
{
title: "Next Week's Focus",
prompt: "What's your main focus for next week?",
},
])
)
Defining the templates actions methods
In this section, we will define a bunch of functions which will be used in our application menu, to execute various actions like:
- Prompt the user to enter the name of the template, then recurrsively prompt it again to create as many sections as he want.
- View All the existing or created templates.
- Use a template to create a journaling file entry.
- Create a new template, from an existing template: The user will be asked to select an existing template then he will have the ability to either use it directly or override its name and sections.
The newly created templates can be used to create new journaling entries (1).
Create a Template :
TemplateActions.ts > createTemplate
import chalk from "chalk"
import inquirer from "inquirer"
import { JournalTemplate } from "./JournalTemplate"
import { registry } from "./registry"
import { editTemplateSections } from "./templateSectionsActions"
import { promptForSectionDetails } from "./utils"
export async function createTemplate(): Promise<void> {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: "input",
name: "name",
message: "Enter a name for the new template:",
},
])
const newTemplate = new JournalTemplate(name, [])
let addMore = true
while (addMore) {
const newSection = await promptForSectionDetails()
newTemplate.addSection(newSection)
const { more } = await inquirer.prompt<{ more: boolean }>([
{
type: "confirm",
name: "more",
message: "Add another section?",
default: false,
},
])
addMore = more
}
registry.addTemplate(name, newTemplate)
console.log(chalk.green(`Template "${name}" created successfully!`))
}
- To create a template we first prompt the user to enter a template name.
- Then we instantiate a new template object, with the name and an empty array for the sections.
- After that, we prompt the user to enter the details of the sections, after entering every section's informations, the user can choose to either stop or enter more sections.
utils.ts > promptForSectionDetails
export async function promptForSectionDetails(): Promise<TemplateSection> {
const { title, prompt } = await inquirer.prompt<{
title: string
prompt: string
}>([
{
type: "input",
name: "title",
message: "Enter section title:",
},
{
type: "input",
name: "prompt",
message: "Enter section prompt:",
},
])
return { title, prompt }
}
The promptForSectionDetails function use the inquirer package to ask for the title, then prompt sequentially from the user.
View The templates :
TemplateActions.ts > viewTemplates
export async function viewTemplates(): Promise<void> {
const allTemplates = registry.getTemplates()
if (allTemplates.length === 0) {
console.log(chalk.yellow("No templates available."))
return
}
console.log(chalk.blue.bold("\nAvailable Templates:"))
allTemplates.forEach((template, index) => {
console.log(chalk.cyan(`\nTemplate #${index + 1}: ${template.name}`))
template.display()
})
await inquirer.prompt<{ continue: string }>([
{
type: "input",
name: "continue",
message: "Press Enter to continue...",
},
])
}
The viewTemplates function works as follow:
- We first get all the templates from the registry , then we loop through the returned templates array and use the display method which we've defined earlier in the JournalTemplate class.
Use a template to create a Journaling Entry : The reason for creating journaling templates, is to make our lives easier when writing our different types of journals, instead of facing an empty page, it's better easier to fill the journal when faced with a bunch of sequential sections titles and prompts.
Let's dive into the useTemplate function:
- First we select one template among the existing templates, after getting the template names from the registery.
- For every section in the template, the user will asked to open his prefered editor to fill the journal section text.
TemplateActions.ts > useTemplate
export async function useTemplate(): Promise<void> {
const allTemplates = registry.getTemplateNames()
const { templateName } = await inquirer.prompt<{ templateName: string }>([
{
type: "list",
name: "templateName",
message: "Select a template to use:",
choices: allTemplates,
},
])
const template = registry.getTemplate(templateName)
if (template) {
console.log(chalk.green(`\nUsing template: ${template.name}`))
for (let i = 0; i < template.getSectionCount(); i++) {
const section = template.getSection(i)
if (section) {
console.log(chalk.yellow(`\n${section.title}`))
console.log(chalk.gray(`${section.prompt}`))
await inquirer.prompt<{ entry: string }>([
{
type: "editor",
name: "entry",
message:
"Write your entry (save and close the editor when finished):",
},
])
}
}
console.log(chalk.green("\nJournal entry completed!"))
} else {
console.log(chalk.red(`Error: Template "${templateName}" not found.`))
}
}
Create a Template From an Existing Template :
Finally, We are going to see the prototype design pattern in action.
Let's explore how can we create new types of templates dynamicly by overriding the existing templates.
- First we prompt the user to select the template that he wants to override from the existing ones.
- Then we prompt it again to type the name of the newly created template.
- We use the registry to get the template given the template name which is selected by the user.
- We use the clone method to get a clone object that matches the selected template.
As you can see from the code bellow, we don't even need to know about the details of the JournalTemplate class or to polute our code by importing it.
TemplateActions.ts > createFromExistingTemplate
- Finally, we set the template name given by the user to the newly created object, and then prompt the user to perform any crud operations on the existing template sections using the editTemplateSections method, which we will be explaining bellow just after the code block.
export async function createFromExistingTemplate(): Promise<void> {
const allTemplates = registry.getTemplateNames()
const { sourceName, newName } = await inquirer.prompt<{
sourceName: string
newName: string
}>([
{
type: "list",
name: "sourceName",
message: "Select a template to use as a base:",
choices: allTemplates,
},
{
type: "input",
name: "newName",
message: "Enter a name for the new template:",
},
])
const sourceTemplate = registry.getTemplate(sourceName)
if (sourceTemplate) {
// Getting the prototype template
const newTemplate = sourceTemplate.clone()
// Editting the prototype template name
newTemplate.setName(newName)
// Editting the prototype template sections
await editTemplateSections(newTemplate)
registry.addTemplate(newName, newTemplate)
console.log(
chalk.green(
`New template "${newName}" created based on "${sourceName}" successfully!`
)
)
} else {
console.log(chalk.red(`Error: Template "${sourceName}" not found.`))
}
}
templateSectionsAction > editTemplateSections
export async function editTemplateSections(
template: JournalTemplate
): Promise<void> {
let editSections = true
while (editSections) {
const { action } = await inquirer.prompt<{ action: string }>([
{
type: "list",
name: "action",
message: "What would you like to do with the sections?",
choices: ["Add Section", "Remove Section", "Edit Section", "Finish"],
},
])
switch (action) {
case "Add Section":
const newSection = await promptForSectionDetails()
template.addSection(newSection)
break
case "Remove Section":
await removeSectionFromTemplate(template)
break
case "Edit Section":
await editSectionInTemplate(template)
break
case "Finish":
editSections = false
break
}
}
}
// ...rest of the code for the crud operations on sections
The editTemplateSections defined bellow basically prompts displays a menu, asking the user to override the existing sections as needed by offering different operations like:
- Add Section
- Remove Section
- Edit Section
Application menu
Finally, We make use of all of the previous functions in our index.ts file, which bootsraps the cli app, and displays a menu with the different template manipulation options:
- Create a Template.
- Create a Template from an Existing Template.
- View Templates.
- Use a Template to create a journaling entry.
- Exit the program.
index.ts
import chalk from "chalk"
import inquirer from "inquirer"
import {
createFromExistingTemplate,
createTemplate,
useTemplate,
viewTemplates,
} from "./templateActions"
async function mainMenu(): Promise<void> {
const { action } = await inquirer.prompt<{ action: string }>([
{
type: "list",
name: "action",
message: "What would you like to do?",
choices: [
"Create Template",
"Create a template from an existing template",
"View Templates",
"Use Template",
"Exit",
],
},
])
switch (action) {
case "Create Template":
await createTemplate()
break
case "Create a template from an existing template":
await createFromExistingTemplate()
break
case "View Templates":
await viewTemplates()
break
case "Use Template":
await useTemplate()
break
case "Exit":
console.log(
chalk.yellow("Thank you for using the Journal Template Manager!")
)
process.exit(0)
}
await mainMenu()
}
console.log(chalk.blue.bold("Welcome to the Journal Template Manager!"))
console.log(
chalk.gray(
"This tool demonstrates the Prototype pattern for managing journal templates.\n"
)
)
mainMenu()
Conclusion
The Prototype design pattern provides a powerful way to create new objects by cloning existing ones. In our journaling template application, we've seen how this pattern allows us to create new templates based on existing ones, demonstrating the flexibility and efficiency of the Prototype pattern.
By using this pattern, we've created a system that's easy to extend and modify, showcasing the true power of object-oriented design patterns in real-world applications.
Contact
If you have any questions or want to discuss something further feel free to Contact me here.
Happy coding!
Top comments (0)