Overview
I’m excited to announce the release of CGP v0.4.2, a major milestone that significantly expands the expressive power of generic programming in Rust. With this release, CGP introduces full support for extensible records and variants, unlocking a range of new capabilities for developers working with highly modular and reusable code.
Extensible records and variants allow developers to write code that operates on any struct containing specific fields or any enum containing specific variants, regardless of their complete definition. This makes it possible to write truly generic and flexible logic that is decoupled from rigid type definitions.
In earlier versions, CGP already offered a foundational feature through the HasField
trait, which made it possible to read a field from any struct that included it. With version 0.4.2, this functionality is dramatically extended. Not only can you now read fields, but you can also construct values onto these fields in a type-safe manner. More importantly, the same level of extensibility is now available for enums, enabling operations over variants in a similarly generic fashion.
This advancement introduces two powerful programming patterns that are now possible with CGP:
-
Extensible Builder Pattern: This pattern allows for modular construction of structs from independent sub-structs, each contributing specific fields. It enables highly composable and decoupled design in data construction.
-
Extensible Visitor Pattern: This pattern enables the modular deconstruction of enums, allowing independent components to handle different variants without requiring full knowledge of the entire enum definition. This effectively enables a modularized version of the visitor pattern, by allowing new variants to be handled by extensible visitors.
For readers coming from more advanced programming languages, this development effectively brings the power of datatype-generic programming, structural typing, row polymorphism and polymorphic variants to Rust. These are advanced type system features commonly found in languages like Haskell, PureScript and OCaml, and their availability in CGP represents a major leap in what is possible with the type system in Rust.
In addition, CGP v0.4.2 introduces support for safe upcasting and downcasting between enums that share a common subset of variants. This provides a foundation for writing extensible and evolvable APIs that remain compatible across different layers of abstraction or across independently maintained modules.
Here is a revised version of your “Content Organization” section, rewritten for clarity, flow, and consistency in tone and style. It maintains full sentences and should read naturally for Rust developers new to CGP:
Content Organization
This article is the first in a five-part series exploring the examples and implementation of extensible data types in CGP. Below is an overview of what each part covers:
Part 1: Modular App Construction and Extensible Builders (this post) – In this introductory part, we present a high-level overview of the key features enabled by extensible data types. We then dive into a hands-on demonstration showing how extensible records can be used to build and compose modular builders for real-world applications.
Part 2: Modular Interpreters and Extensible Visitors – This part continues the demonstration by introducing extensible variants. We use them to address the expression problem, implementing a set of reusable interpreter components for a small toy language.
Part 3: Implementing Extensible Records – Here, we walk through the internal mechanics behind extensible records. We show how CGP supports the modular builder pattern demonstrated in Part 1 through its underlying type and trait machinery.
Part 4: Implementing Extensible Variants – This part mirrors Part 3, but for extensible variants. We examine how extensible variants are implemented, and compare the differences and similarities between extensible records and variants.
Part 5: Handler Hierarchy and Conclusion – In the final part, we explore how extensible data types integrate into the broader CGP handler hierarchy — from Computer
to Handler
— to support all forms of programs from pure computations to async I/O with failures. We conclude the series with a summary of key takeaways and the design philosophy behind CGP.
Feature Highlighs
Safe Enum Upcasting
Let’s begin by looking at how CGP enables safe upcasting between enums. Imagine you have the following enum definition called Shape
:
#[derive(HasFields, FromVariant, ExtractField)]
pub enum Shape {
Circle(Circle),
Rectangle(Rectangle),
}
You may also have a different ShapePlus
enum, defined elsewhere, that represents a superset of the variants in Shape
:
#[derive(HasFields, FromVariant, ExtractField)]
pub enum ShapePlus {
Triangle(Triangle),
Rectangle(Rectangle),
Circle(Circle),
}
With CGP v0.4.2, it is now possible to upcast a Shape
value into a ShapePlus
value in fully safe Rust:
let shape = Shape::Circle(Circle { radius: 5.0 });
let shape_plus = shape.upcast(PhantomData::<ShapePlus>);
assert_eq!(shape_plus, ShapePlus::Circle(Circle { radius: 5.0 }));
This operation works by leveraging the derived CGP traits HasFields
, ExtractField
, and FromVariant
. As long as the source enum’s variants are a subset of the target enum’s, CGP can automatically generate the logic required to lift the smaller enum into the larger one.
A particularly powerful aspect of this design is that the two enums do not need to know about each other. They can be defined in entirely separate crates, and the trait derivations are completely general. You don’t need to define any enum-specific conversion traits. This makes it possible to build libraries of reusable variant groups and compose them freely in application code.
Safe Enum Downcasting
In the reverse direction, CGP also supports safe downcasting from a larger enum to a smaller one that contains only a subset of its variants. Using the same Shape
and ShapePlus
enums, the following example demonstrates how this works:
let shape = ShapePlus::Circle(Circle { radius: 5.0 });
assert_eq!(
shape.downcast(PhantomData::<Shape>).ok(),
Some(Shape::Circle(Circle { radius: 5.0 }))
);
Like upcast
, this downcast
method relies on the same set of derived CGP traits and works for any pair of compatible enums. The operation returns a Result
, where the Ok
variant contains the downcasted value, and the Err
variant carries the unhandled remainder of the original enum.
In the example above, we use .ok()
to simplify the comparison, but in practice, the Err
case contains useful remainder value that can be further examined or downcasted again.
Safe Exhaustive Downcasting
One of the unique capabilities CGP provides is the ability to exhaustively downcast an enum, step by step, until all possible variants are handled. This pattern becomes especially useful when working with generic enums in extensible APIs, where the concrete enum definition is unknown or evolving.
To demonstrate this, suppose we define another enum to represent the remaining Triangle
variant:
#[derive(HasFields, ExtractField, FromVariant)]
pub enum TriangleOnly {
Triangle(Triangle),
}
Now, the combination of Shape
and TriangleOnly
covers the entire set of variants from ShapePlus
. We can use this setup to exhaustively handle all possible cases, while staying entirely within the bounds of safe Rust:
let shape_plus = ShapePlus::Triangle(Triangle {
base: 3.0,
height: 4.0,
});
let area = match shape_plus.downcast(PhantomData::<Shape>) {
Ok(shape) => match shape {
Shape::Circle(circle) => PI * circle.radius * circle.radius,
Shape::Rectangle(rectangle) => rectangle.width * rectangle.height,
},
Err(remainder) => match remainder.downcast_fields(PhantomData::<TriangleOnly>) {
Ok(TriangleOnly::Triangle(triangle)) => triangle.base * triangle.height / 2.0,
},
};
In this example, we first attempt to downcast into Shape
. If that fails, the remainder is passed to downcast_fields
, which attempts to further downcast to TriangleOnly
. When all variants are properly handled, Rust automatically knows that there is no variant left to be handled, and we can safely omit the final Err
case.
At first glance, this approach may appear more complex than simply matching against the original enum directly. However, its true strength lies in its generality. With CGP’s downcasting mechanism, you can pattern match over generic enum types without knowing their full structure in advance. This enables highly extensible and type-safe designs where variants can be added or removed modularly, without breaking existing logic.
Safe Struct Building
Just as CGP enables safe, composable deconstruction of enums, it also brings extensible construction to structs. This is achieved through a form of structural merging, where smaller structs can be incrementally combined into larger ones. The result is a flexible and modular approach to building complex data types, well-suited for highly decoupled or plugin-style architectures.
To illustrate this, let’s take the example of a Employee
struct:
#[derive(HasFields, BuildField)]
pub struct Employee {
pub employee_id: u64,
pub first_name: String,
pub last_name: String,
}
Suppose we also define two smaller structs — Person
and EmployeeId
— each containing a subset of the fields in Employee
:
#[derive(HasFields, BuildField)]
pub struct Person {
pub first_name: String,
pub last_name: String,
}
#[derive(HasFields, BuildField)]
pub struct EmployeeId {
pub employee_id: u64,
}
With CGP, we can now construct a Employee
value in a modular and extensible way, by composing these smaller building blocks:
let person = Person {
first_name: "John".to_owned(),
last_name: "Smith".to_owned(),
};
let employee_id = EmployeeId { employee_id: 1 };
let employee = Employee::builder()
.build_from(person)
.build_from(employee_id)
.finalize_build();
Here’s what’s happening: The builder()
method on Employee
initiates a partial record builder, an intermediate structure that initially contains none of the target fields. Each call to build_from
takes a struct that contributes one or more of the remaining fields and returns a new builder with those fields filled in. Once all required fields have been supplied, the finalize_build()
method consumes the builder and produces a fully constructed Employee
instance.
Just like enum upcasting and downcasting, the struct builder is implemented entirely in safe, panic-free Rust. There’s no runtime reflection or unsafe code involved. The only requirement is that the participating structs must have compatible fields and derive the CGP-provided traits HasFields
and BuildField
.
Moreover, this system is completely decoupled from specific struct definitions. The individual component structs — Person
, EmployeeId
, and Employee
— can be defined in separate crates, with no awareness of each other. Once the CGP traits are derived, they become interoperable through structural field compatibility alone.
While this example may seem trivial — after all, constructing Employee
directly is straightforward — it serves as a foundation for much more powerful generic abstractions. As you’ll see in the upcoming sections, the builder pattern opens the door to writing highly reusable, type-safe logic that can construct generic types without ever referencing their concrete types. This makes it possible to write libraries or plugins that contribute data to a shared structure without tight coupling or dependency on a central type definition.
Motivation for Extensible Builders
To understand how extensible records enable modular builders, let’s explore a practical use case: constructing an application context from configuration inputs.
Imagine we’re building an API client for our application. The application context needs to include an SQLite database connection and an HTTP client. A typical way to model this in Rust would be to define a struct like the following:
#[cgp_context]
pub struct App {
pub sqlite_pool: SqlitePool,
pub http_client: Client,
}
This App
struct holds a SqlitePool
from the sqlx
crate, and an HTTP Client
from reqwest
. To construct this context, we might implement a new
function as follows:
impl App {
pub async fn new(db_path: &str) -> Result<Self, Error> {
let http_client = Client::new();
let sqlite_pool = SqlitePool::connect(db_path).await?;
Ok(Self {
http_client,
sqlite_pool,
})
}
}
This constructor is asynchronous and returns a Result<App, Error>
. It creates a default Client
using reqwest
, connects to the database using the provided path, and assembles both into an App
struct.
Adding AI Capabilities to App
At this point, the constructor looks simple. But in a real-world setting, it’s rarely that clean. Suppose the product team now wants to integrate AI capabilities into the application. To support this, we decide to use an LLM service like ChatGPT and extend the App
struct accordingly:
#[cgp_context]
pub struct App {
pub sqlite_pool: SqlitePool,
pub http_client: Client,
pub open_ai_client: openai::Client,
pub open_ai_agent: Agent<openai::CompletionModel>,
}
In this updated version, we introduce two new fields: open_ai_client
, which is used to communicate with the OpenAI API, and open_ai_agent
, which encapsulates a configured agent that can perform conversational tasks using a model like GPT-4o using rig
.
The new
constructor must now also handle the initialization logic for these fields:
impl App {
pub async fn new(db_path: &str) -> Result<Self, Error> {
let http_client = Client::new();
let sqlite_pool = SqlitePool::connect(db_path).await?;
let open_ai_client = openai::Client::from_env();
let open_ai_agent = open_ai_client.agent("gpt-4o").build();
Ok(Self {
http_client,
sqlite_pool,
open_ai_client,
open_ai_agent,
})
}
}
Here, we initialize the OpenAI client using environment variables, and then build an agent configured for the gpt-4o
model. These values are added alongside the existing HTTP and database clients.
From Simple to Complex
Even with these additions, our constructor remains relatively manageable. However, as often happens in production, the requirements grow—and so does the configuration logic. Let’s imagine a more realistic version of this new
function:
impl App {
pub async fn new(
db_options: &str,
db_journal_mode: &str,
http_user_agent: &str,
open_ai_key: &str,
open_ai_model: &str,
llm_preamble: &str,
) -> Result<Self, Error> {
let journal_mode = SqliteJournalMode::from_str(db_journal_mode)?;
let db_options = SqliteConnectOptions::from_str(db_options)?.journal_mode(journal_mode);
let sqlite_pool = SqlitePool::connect_with(db_options).await?;
let http_client = Client::builder()
.user_agent(http_user_agent)
.connect_timeout(Duration::from_secs(5))
.build()?;
let open_ai_client = openai::Client::new(open_ai_key);
let open_ai_agent = open_ai_client
.agent(open_ai_model)
.preamble(llm_preamble)
.build();
Ok(Self {
open_ai_client,
open_ai_agent,
sqlite_pool,
http_client,
})
}
}
This constructor now handles five separate input parameters, each contributing to the configuration of different parts of the application. It creates a SqliteConnectOptions
object to configure the database with the specified journal mode. The HTTP client is set up with a custom user agent and a longer timeout. The AI client is initialized using an explicit API key, and the agent is constructed with a custom model and preamble.
While none of these steps are especially difficult on their own, the function is starting to grow in complexity. It’s also becoming more fragile, as all responsibilities are bundled into one place. Every change to a single subsystem — whether it’s database, HTTP, or AI — requires editing the same constructor.
Why Modular Constructor Matters
As we've seen in the previous example, even modest configurability can cause a constructor's complexity to grow rapidly. With just a few additional fields or customization options, the function becomes harder to maintain, test, and reason about.
In many cases, there's no single “correct” way to construct an application context. For example, you might want to retain both versions of the new
constructor from earlier: a minimal one for unit tests with default values, and a more elaborate, configurable one for production. In fact, it's common for different parts of an application to require different levels of configurability—some using defaults, others requiring fine-grained setup.
To manage this complexity, Rust developers often reach for the builder pattern. This involves creating a separate builder struct, typically with optional or defaultable fields and fluent setter methods. The builder is used to gradually assemble values before producing the final struct.
Challenges for Modular Builders
The traditional builder pattern works, but it comes with serious limitations — especially when extensibility and modularity are important.
The first limitation is tight coupling. A builder is usually tied directly to a specific target struct. If you create a new context that’s only slightly different from an existing one, you often have to duplicate the entire builder implementation, even if most of the logic is the same.
Second, builders are typically non-extensible. If you want to extend the construction logic — say, by adding a new step to initialize an additional field — you usually have to modify the original builder struct. This makes it hard to share construction logic across crates or teams without exposing internal implementation details.
The root cause of these problems is that struct construction in Rust typically requires direct access to the concrete type. That means the builder must know the exact shape of the final struct and have access to all its field values up front. If you need intermediate values or want to plug in custom build steps, those values must be manually threaded through the builder and its state.
This rigidity makes it difficult to define reusable, composable building blocks—especially in large or evolving codebases.
Modular Builders with CGP
Earlier versions of CGP also ran into these limitations. When writing context-generic code, we wanted to construct structs in a way that didn’t require knowing their concrete types ahead of time. But because Rust structs require all field values to be present simultaneously at construction time, we couldn’t easily implement flexible or reusable context-generic constructors.
With the latest release, that limitation is fully resolved.
CGP now supports modular, extensible struct builders that can be composed from smaller, independent parts. Each module can define how to build a piece of a context struct, and the builder automatically merges them — without needing to know the final shape of the struct ahead of time.
This opens the door to a new style of constructor logic: one that is modular, composable, and context-generic. You can define builders for individual subsystems (e.g., database, HTTP client, AI agent), and combine them to build any compatible application context.
Extensible Builders
In this section, we’ll revisit the constructor examples we’ve already seen — and show how to rewrite them using CGP’s new builder pattern to achieve clean, modular, and reusable construction logic. A full version of the example code covered in this section is available on our GitHub repository
Modular SQLite Builder
Let’s now explore how to implement modular construction of the App
context using multiple CGP providers. We’ll start by defining a default SQLite builder provider using CGP's Handler
component:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildDefaultSqliteClient
where
Build: HasSqlitePath + CanRaiseAsyncError<sqlx::Error>,
{
type Output = SqliteClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let sqlite_pool = SqlitePool::connect(build.db_path())
.await
.map_err(Build::raise_error)?;
Ok(SqliteClient { sqlite_pool })
}
}
In this example, we define BuildDefaultSqliteClient
as a CGP provider that implements the Handler
component. This is the same Handler
trait we introduced in Hypershell, where it was used to power shell-like pipelines. Here, we repurpose the same trait to construct modular context components. This demonstrates how general-purpose the Handler
trait is — it can be used for pipelines, API handlers, visitors, and now, context builders.
The Build
type parameter refers to a generic builder context, not the final App
struct. This context includes the inputs required to construct a SqliteClient
. In this case, the builder must be able to provide a database path, as well as a way to raise errors from sqlx
. These requirements are expressed through the HasSqlitePath
and CanRaiseAsyncError
constraints.
The HasSqlitePath
trait is defined as follows:
#[cgp_auto_getter]
pub trait HasSqlitePath {
fn db_path(&self) -> &str;
}
By marking the trait with #[cgp_auto_getter]
, CGP can automatically implement this trait for any builder context that contains a db_path
field of type String
. This automatic implementation reduces boilerplate and ensures that any context with the appropriate fields can satisfy the trait bounds.
Although our example does not make use of the Code
or Input
parameters, they remain part of the Handler
signature. The Code
parameter may be used for compile-time options that allow contexts to be constructed in multiple ways. Meanwhile, Input
typically refers to the partial value of the final struct being built. These capabilities are useful in more advanced scenarios, but we will leave their explanation for a later section.
In this implementation, the handle
method simply connects to the SQLite database using the provided path, wraps the resulting pool in a SqliteClient
struct, and returns it. The SqliteClient
is defined as:
#[derive(HasField, HasFields, BuildField)]
pub struct SqliteClient {
pub sqlite_pool: SqlitePool,
}
This struct acts as a wrapper around SqlitePool
and serves as the output of our modular builder. Although BuildDefaultSqliteClient
does not build the full App
context, we can merge its output into App
using CGP’s build_from
mechanism we covered earlier. Deriving HasField
, HasFields
, and BuildField
on SqliteClient
allows it to be safely and automatically merged into the final context during composition.
At this point, you might be wondering why so much infrastructure is needed just to call SqlitePool::connect
. The answer is that, while this example is simple, real-world construction logic can be much more complex. By encapsulating each part of the logic into modular components, we gain flexibility, reusability, and testability.
To demonstrate this flexibility, consider a more complex version of the SQLite builder. This version uses connection options and journal mode configuration rather than a simple path string:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildSqliteClient
where
Build: HasSqliteOptions + CanRaiseAsyncError<sqlx::Error>,
{
type Output = SqliteClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let journal_mode =
SqliteJournalMode::from_str(build.db_journal_mode()).map_err(Build::raise_error)?;
let db_options = SqliteConnectOptions::from_str(build.db_options())
.map_err(Build::raise_error)?
.journal_mode(journal_mode);
let sqlite_pool = SqlitePool::connect_with(db_options)
.await
.map_err(Build::raise_error)?;
Ok(SqliteClient { sqlite_pool })
}
}
#[cgp_auto_getter]
pub trait HasSqliteOptions {
fn db_options(&self) -> &str;
fn db_journal_mode(&self) -> &str;
}
In this version, BuildSqliteClient
constructs a SqliteClient
using fully configurable connection options. The Build
context must now implement HasSqliteOptions
, a trait that provides both the connection URI and the desired journal mode.
This example illustrates the key advantage of modular builders: the builder logic is entirely decoupled from the context itself. If we want to use BuildDefaultSqliteClient
, we can define a simple builder context with just a db_path
field. If we switch to BuildSqliteClient
, we only need to provide a different context that includes db_options
and db_journal_mode
. All other components of the builder can remain unchanged.
Thanks to this decoupling, we can easily swap in different builder providers depending on the needs of the environment — be it development, testing, or production — without rewriting the entire construction logic. This modularity makes CGP builders highly scalable and adaptable to real-world applications.
HTTP Client Builder
Just as we modularized the construction of the SQLite client, we can also define a modular builder for an HTTP client using CGP. In this case, we will construct a custom reqwest
client with specific configuration options. To keep the focus on advanced use cases, we will skip the simpler version and go directly to the more complex construction logic.
The HTTP client builder is implemented as follows:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildHttpClient
where
Build: HasHttpClientConfig + CanRaiseAsyncError<reqwest::Error>,
{
type Output = HttpClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let http_client = Client::builder()
.user_agent(build.http_user_agent())
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(Build::raise_error)?;
Ok(HttpClient { http_client })
}
}
This provider, BuildHttpClient
, is structured very similarly to BuildSqliteClient
. It implements the Handler
trait and defines HttpClient
as its output. The Build
context is required to implement two traits: HasHttpClientConfig
, which supplies the necessary configuration values, and CanRaiseAsyncError<reqwest::Error>
, which allows the context to convert reqwest
errors into its own error type.
The required configuration is minimal. In this case, we only need a user agent string, which is defined through the following trait:
#[cgp_auto_getter]
pub trait HasHttpClientConfig {
fn http_user_agent(&self) -> &str;
}
As with the previous examples, the #[cgp_auto_getter]
macro ensures that this trait is automatically implemented for any context that includes a http_user_agent
field.
The output of this builder is a simple wrapper around reqwest::Client
:
#[derive(HasField, HasFields, BuildField)]
pub struct HttpClient {
pub http_client: Client,
}
Here again, we derive HasField
, HasFields
, and BuildField
to support field merging into the final context later on. This makes the HttpClient
output compatible with CGP’s build_from
mechanism, allowing it to be composed with other builder outputs.
The handle
method creates a new reqwest::Client
using the client builder from reqwest
. It sets the user agent using a value from the context, and specifies a connection timeout of five seconds. The constructed client is then wrapped in the HttpClient
struct and returned.
Although this example remains relatively simple, it illustrates how each field or component in a context can be modularly constructed using dedicated builder logic. Each builder is independently defined, type-safe, and reusable. If the way we configure our HTTP client changes — for example, if we want to support proxies or TLS settings — we can define a new provider that implements a different construction strategy, without needing to change any of the other components in our application context.
Combined SQLite and HTTP Client Builder
Before we move on, it is important to emphasize that CGP does not require you to break down the construction logic of every component in your application context into separate builders. While the modular approach can offer more flexibility and reuse, you are entirely free to combine multiple construction tasks into a single provider if that better suits your needs.
For example, here is how you might implement a single builder that constructs both the SQLite client and the HTTP client together:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildDefaultSqliteAndHttpClient
where
Build: HasSqlitePath + CanRaiseAsyncError<sqlx::Error>,
{
type Output = SqliteAndHttpClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let sqlite_pool = SqlitePool::connect(build.db_path())
.await
.map_err(Build::raise_error)?;
let http_client = Client::new();
Ok(SqliteAndHttpClient { sqlite_pool, http_client })
}
}
#[derive(HasField, HasFields, BuildField)]
pub struct SqliteAndHttpClient {
pub sqlite_pool: SqlitePool,
pub http_client: Client,
}
In this implementation, we define a single provider BuildDefaultSqliteAndHttpClient
that returns a combined struct SqliteAndHttpClient
, which contains both a SqlitePool
and a reqwest::Client
. The construction logic is written in one place, which can be convenient when these components are always used together or when their configuration is tightly integrated.
However, the tradeoff of this approach is that it reduces flexibility. This tight coupling can limit reuse and make future changes more difficult.
That said, the choice of whether to combine or separate builders is entirely up to you. CGP does not impose any rules on how you must structure your builder logic. It provides the tools to compose and reuse components where helpful, but it leaves design decisions to the developer.
For the remainder of this article, we will continue to use the fully modular approach, breaking construction logic down into smaller, independent units. Our goal is to illustrate the full extent of flexibility and reusability that CGP enables. However, if you prefer a different organizational structure, you are free to structure your builders in whatever way best suits your project.
ChatGPT Client Builder
Regardless of whether you prefer to split or combine the construction of components such as the SQLite and HTTP clients, there are many situations where it makes sense to separate construction logic into smaller, more focused units. For instance, you might want to offer two versions of your application — one standard version and one "smart" version that includes AI capabilities. In such cases, it is useful to define a separate builder provider for the ChatGPT client, so that AI-related logic can be included only when necessary.
The implementation for the ChatGPT client builder follows the same general pattern as the previous builders. It is defined as follows:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildOpenAiClient
where
Build: HasOpenAiConfig + HasAsyncErrorType,
{
type Output = OpenAiClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let open_ai_client = openai::Client::new(build.open_ai_key());
let open_ai_agent = open_ai_client
.agent(build.open_ai_model())
.preamble(build.llm_preamble())
.build();
Ok(OpenAiClient {
open_ai_client,
open_ai_agent,
})
}
}
This builder requires the Build
context to provide three string fields: the OpenAI API key, the model name, and a custom preamble string. These requirements are captured by the HasOpenAiConfig
trait:
#[cgp_auto_getter]
pub trait HasOpenAiConfig {
fn open_ai_key(&self) -> &str;
fn open_ai_model(&self) -> &str;
fn llm_preamble(&self) -> &str;
}
As with the other providers, we use the #[cgp_auto_getter]
macro to automatically implement the trait, as long as the builder context contains the corresponding fields and derives HasField
.
The BuildOpenAiClient
provider returns an OpenAiClient
struct that wraps two values: the low-level openai::Client
and the higher-level Agent
configured with the specified model and preamble.
#[derive(HasField, HasFields, BuildField)]
pub struct OpenAiClient {
pub open_ai_client: openai::Client,
pub open_ai_agent: Agent<openai::CompletionModel>,
}
By defining this logic in a standalone builder provider, we can easily opt in or out of ChatGPT support in our application context.
Builder Context
Now that we have implemented builder providers for SQLite, HTTP, and ChatGPT clients, we can demonstrate how to combine them in a complete builder context that constructs the final App
instance. Defining this context is surprisingly concise and requires only a few lines of code:
#[cgp_context]
#[derive(HasField, Deserialize)]
pub struct FullAppBuilder {
pub db_options: String,
pub db_journal_mode: String,
pub http_user_agent: String,
pub open_ai_key: String,
pub open_ai_model: String,
pub llm_preamble: String,
}
Here, we define a FullAppBuilder
struct that includes all of the fields required by the three individual builder providers. The #[cgp_context]
macro enables the CGP capabilities for the context struct, while the HasField
derive macro enables automatic implementation of the necessary accessor traits using #[cgp_auto_getter]
. In addition, we derive Deserialize
so that FullAppBuilder
can be easily loaded from a configuration file in formats such as JSON or TOML.
Next, we wire up the builder context using the delegate_components!
macro:
delegate_components! {
FullAppBuilderComponents {
ErrorTypeProviderComponent:
UseAnyhowError,
ErrorRaiserComponent:
RaiseAnyhowError,
HandlerComponent:
BuildAndMergeOutputs<
App,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildOpenAiClient,
]>,
}
}
This macro allows us to delegate the implementation of various components of the builder context. First, we configure error handling by using the cgp-anyhow-error
library. The UseAnyhowError
provider specifies that our abstract Error
type will be instantiated to anyhow::Error
, and the RaiseAnyhowError
provider allows conversion from error types implementing core::error::Error
, like sqlx::Error
and reqwest::Error
, into anyhow::Error
.
Builder Dispatcher
In the example above, we used a special builder dispatcher called BuildAndMergeOutputs
to implement the HandlerComponent
. This dispatcher allows us to construct the final App
type by sequentially combining the outputs of multiple builder providers. We specify the target App
type as the output of the build process, and then pass in a type-level list of builder providers using the Product!
macro. In this case, we used BuildSqliteClient
, BuildHttpClient
, and BuildOpenAiClient
, all of which we implemented previously.
To understand how BuildAndMergeOutputs
operates under the hood, let us walk through a manual implementation that performs the same task:
#[cgp_new_provider]
impl<Code: Send, Input: Send> Handler<FullAppBuilder, Code, Input> for BuildApp {
type Output = App;
async fn handle(context: &FullAppBuilder, code: PhantomData<Code>, _input: Input) -> Result<App, Error> {
let app = App::builder()
.build_from(BuildSqliteClient::handle(context, code, ()).await?)
.build_from(BuildHttpClient::handle(context, code, ()).await?)
.build_from(BuildOpenAiClient::handle(context, code, ()).await?)
.finalize_build();
Ok(app)
}
}
This manual implementation demonstrates the boilerplate that would be necessary if we did not use BuildAndMergeOutputs
. Here, we define BuildApp
as a context-specific provider for the FullAppBuilder
context. It implements the Handler
trait for any Code
and Input
types.
Within the handle
method, we construct the App
in a step-by-step manner, similar to how we built complex types earlier in the safe struct building section. We begin by initializing an empty builder with App::builder()
. Next, we invoke the handle
method on each of the individual providers — BuildSqliteClient
, BuildHttpClient
, and BuildOpenAiClient
— passing them the shared context and PhantomData
for the code. The resulting outputs are incrementally merged into the builder using build_from
, and finally, finalize_build
is called to produce the completed App
instance.
In this example, we ignore the original Input
parameter and instead pass ()
to each sub-handler for simplicity. In the actual implementation of BuildAndMergeOutputs
, a reference to the intermediate builder is instead passed along as input to each sub-handler to support more advanced use cases. However, we have omitted that detail here to focus on the overall structure.
While the manual implementation of BuildApp
is relatively easy to follow, it is also quite repetitive. The main benefit of BuildAndMergeOutputs
is that it eliminates this boilerplate by abstracting away the repetitive logic of chaining multiple builder steps and threading intermediary results. Furthermore, BuildAndMergeOutputs
is implemented with the necessary generic parameters and constraints to work with any context type, as compared to being tied to the App
context that we defined.
Aside from this reduction in verbosity, the behavior remains conceptually the same as what is shown in the manual example.
Building the App
With the builder context defined, we can now construct the full App
by simply instantiating the builder and calling its handle
method:
async fn main() -> Result<(), Error> {
let builder = FullAppBuilder {
db_options: "file:./db.sqlite".to_owned(),
db_journal_mode: "WAL".to_owned(),
http_user_agent: "SUPER_AI_AGENT".to_owned(),
open_ai_key: "1234567890".to_owned(),
open_ai_model: "gpt-4o".to_owned(),
llm_preamble: "You are a helpful assistant".to_owned(),
};
let app = builder.handle(PhantomData::<()>, ()).await?;
/* Call methods on the app here */
Ok(())
}
In this example, we initialize FullAppBuilder
by filling in the required configuration values. We then call builder.handle()
to construct the App
. The handle
method requires two arguments: a Code
type and an Input
value. However, because neither of these are constrained in any way in our example, we can simply pass any type we want, such as the unit type ()
for both. This simplifies to the equivalent of calling builder.handle()
with no argument in practice.
This example illustrates how CGP allows new builder contexts to be defined with minimal effort by composing multiple independent builder providers — none of which require knowledge of the final type being constructed.
Rather than writing custom constructor functions that take numerous arguments, we define a builder struct where each required input becomes a field. Instead of manually constructing each component of the context, we use delegate_components!
to connect the appropriate builder providers, which handle the construction logic for us.
By embracing this modular builder approach, our code becomes not only more extensible, but also easier to read, test, and maintain.
More Builder Examples
At this point, some readers may still be skeptical about the value of modularity offered by CGP builders. Since we’ve only shown a single application context and one corresponding builder context so far, it might not be obvious why we couldn’t just use a simple new
constructor function like the one defined at the beginning.
To truly demonstrate the power of modular builders, it’s helpful to explore how CGP makes it easy to define multiple contexts that are similar but have slight differences. However, if you're an advanced reader already familiar with the benefits of modular design, feel free to skip ahead to the conclusion.
Default Builder
Earlier, we introduced default builders like BuildDefaultSqliteClient
, which can construct an App
with default configuration values. These defaults can be combined to define a minimal builder for App
:
#[cgp_context]
#[derive(HasField, Deserialize)]
pub struct DefaultAppBuilder {
pub db_path: String,
}
delegate_components! {
DefaultAppBuilderComponents {
...
HandlerComponent:
BuildAndMergeOutputs<
App,
Product![
BuildDefaultSqliteClient,
BuildDefaultHttpClient,
BuildDefaultOpenAiClient,
]>,
}
}
In this context, the only required configuration is the db_path
, simplifying the process of constructing an App
, especially for use cases like unit testing or demos.
Postgres App
Now suppose we want an enterprise version of the app that uses Postgres instead of SQLite. We can define a new App
context that swaps in PgPool
:
#[cgp_context]
#[derive(HasField, HasFields, BuildField)]
pub struct App {
pub postgres_pool: PgPool,
pub http_client: Client,
pub open_ai_client: openai::Client,
pub open_ai_agent: Agent<openai::CompletionModel>,
}
Since the HTTP and ChatGPT logic remains unchanged, we only need to implement a new builder for Postgres:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildPostgresClient
where
Build: HasPostgresUrl + CanRaiseAsyncError<sqlx::Error>,
{
type Output = PostgresClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let postgres_pool = PgPool::connect(build.postgres_url())
.await
.map_err(Build::raise_error)?;
Ok(PostgresClient { postgres_pool })
}
}
#[cgp_auto_getter]
pub trait HasPostgresUrl {
fn postgres_url(&self) -> &str;
}
#[derive(HasField, HasFields, BuildField)]
pub struct PostgresClient {
pub postgres_pool: PgPool,
}
This builder closely mirrors the SQLite version, but reads the postgres_url
field from the context instead.
Next, we define a new builder context that includes Postgres configuration:
#[cgp_context]
#[derive(HasField, Deserialize)]
pub struct AppBuilder {
pub postgres_url: String,
pub http_user_agent: String,
pub open_ai_key: String,
pub open_ai_model: String,
pub llm_preamble: String,
}
delegate_components! {
AppBuilderComponents {
...
HandlerComponent:
BuildAndMergeOutputs<
App,
Product![
BuildPostgresClient,
BuildHttpClient,
BuildOpenAiClient,
]>,
}
}
Here, we simply swap in BuildPostgresClient
instead of BuildSqliteClient
, while reusing the other builder providers unchanged.
This example highlights a key advantage of CGP over traditional feature flags: with CGP, multiple application variants (e.g., SQLite or Postgres) can coexist in the same codebase and even be compiled together. In contrast, feature flags often force a binary either/or split at compile time.
By enabling different configurations to exist side-by-side, CGP improves testability and reduces the likelihood of missing edge cases caused by untested feature combinations.
Anthropic App
Just as we swapped SQLite for Postgres earlier, we can also substitute the AI model used in the application — such as replacing ChatGPT with Claude. With CGP, this becomes straightforward: we simply define a new AnthropicApp
that uses the Anthropic client and agent:
#[cgp_context]
#[derive(HasField, HasFields, BuildField)]
pub struct AnthropicApp {
pub sqlite_pool: SqlitePool,
pub http_client: Client,
pub anthropic_client: anthropic::Client,
pub anthropic_agent: Agent<anthropic::completion::CompletionModel>,
}
Next, we implement a builder provider to construct the Claude client:
#[cgp_new_provider]
impl<Build, Code: Send, Input: Send> Handler<Build, Code, Input> for BuildDefaultAnthropicClient
where
Build: HasAnthropicConfig + HasAsyncErrorType,
{
type Output = AnthropicClient;
async fn handle(
build: &Build,
_code: PhantomData<Code>,
_input: Input,
) -> Result<Self::Output, Build::Error> {
let anthropic_client = ClientBuilder::new(build.anthropic_key())
.anthropic_version(ANTHROPIC_VERSION_LATEST)
.build();
let anthropic_agent = anthropic_client
.agent(anthropic::CLAUDE_3_7_SONNET)
.preamble(build.llm_preamble())
.build();
Ok(AnthropicClient {
anthropic_client,
anthropic_agent,
})
}
}
#[cgp_auto_getter]
pub trait HasAnthropicConfig {
fn anthropic_key(&self) -> &str;
fn llm_preamble(&self) -> &str;
}
#[derive(HasField, HasFields, BuildField)]
pub struct AnthropicClient {
pub anthropic_client: anthropic::Client,
pub anthropic_agent: Agent<CompletionModel>,
}
With the builder provider in place, we define a new builder context that includes the Anthropic API key and wire it up using BuildDefaultAnthropicClient
:
#[cgp_context]
#[derive(HasField, Deserialize)]
pub struct AppBuilder {
pub db_options: String,
pub db_journal_mode: String,
pub http_user_agent: String,
pub anthropic_key: String,
pub llm_preamble: String,
}
delegate_components! {
AppBuilderComponents {
...
HandlerComponent:
BuildAndMergeOutputs<
AnthropicApp,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildDefaultAnthropicClient,
]>,
}
}
This example shows how effortlessly CGP supports variation and customization. The same modular pattern can be reused to swap in different components — databases, HTTP clients, or agents — without rewriting core application logic.
In fact, the process becomes so systematic that it’s easy to imagine an AI tool like Claude Code automating the entire setup given the right prompt and documentation.
Anthropic and ChatGPT Builder
It’s impressive that CGP lets us easily swap ChatGPT for Claude. But what’s even better is that we don’t have to choose at all — we can include both AI agents in the same application.
This could be useful for scenarios where combining the strengths of multiple models improves the overall intelligence or reliability of your application. More importantly, it demonstrates that CGP is not just about selecting one provider over another — it’s also about composing multiple providers together in a clean, modular way.
We begin by defining an AnthropicAndChatGptApp
context that includes both Claude and ChatGPT clients:
#[cgp_context]
#[derive(HasField, HasFields, BuildField)]
pub struct AnthropicAndChatGptApp {
pub sqlite_pool: SqlitePool,
pub http_client: Client,
pub anthropic_client: anthropic::Client,
pub anthropic_agent: Agent<anthropic::completion::CompletionModel>,
pub open_ai_client: openai::Client,
pub open_ai_agent: Agent<openai::CompletionModel>,
}
Next, we define a builder context that includes configuration fields for both AI platforms:
#[cgp_context]
#[derive(HasField, Deserialize)]
pub struct AnthropicAndChatGptAppBuilder {
pub db_options: String,
pub db_journal_mode: String,
pub http_user_agent: String,
pub anthropic_key: String,
pub open_ai_key: String,
pub open_ai_model: String,
pub llm_preamble: String,
}
In the component wiring, we include both BuildDefaultAnthropicClient
and BuildOpenAiClient
in the provider list:
delegate_components! {
AnthropicAndChatGptAppBuilderComponents {
...
HandlerComponent:
BuildAndMergeOutputs<
AnthropicAndChatGptApp,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildDefaultAnthropicClient,
BuildOpenAiClient,
]>,
}
}
With just a few extra lines, we’ve created a dual-agent AI app that can leverage both Claude and ChatGPT simultaneously.
It’s also worth noting that the llm_preamble
field is reused by both the Claude and ChatGPT builders. This demonstrates CGP’s flexibility in sharing input values across multiple providers—without requiring any manual coordination or boilerplate.
This kind of seamless reuse and composition is where CGP truly shines: giving you fine-grained control over how your application is assembled, while keeping your code modular and maintainable.
Multi-Context Builder
Looking closely at the AnthropicAndChatGptAppBuilder
that we previously defined, we can observe that it already includes all the necessary fields required to construct the Claude-only and ChatGPT-only applications as well. This means we can reuse the same builder to construct all three versions of our application contexts, simply by changing how the builder is wired.
To achieve this, we take advantage of the Code
type parameter, which allows us to emulate DSL-like behavior similar to what is seen in Hypershell. We begin by defining distinct marker types that represent the different build modes:
pub struct BuildChatGptApp;
pub struct BuildAnthropicApp;
pub struct BuildAnthropicAndChatGptApp;
Using these types, we can apply the UseDelegate
pattern to route the Handler
implementation to different builder pipelines depending on the code passed in. This enables conditional wiring based on the selected application mode:
delegate_components! {
AnthropicAndChatGptAppBuilderComponents {
...
HandlerComponent:
UseDelegate<new BuilderHandlers {
BuildAnthropicAndChatGptApp:
BuildAndMergeOutputs<
AnthropicAndChatGptApp,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildDefaultAnthropicClient,
BuildOpenAiClient,
]>,
BuildChatGptApp:
BuildAndMergeOutputs<
ChatGptApp,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildOpenAiClient,
]>,
BuildAnthropicApp:
BuildAndMergeOutputs<
AnthropicApp,
Product![
BuildSqliteClient,
BuildHttpClient,
BuildDefaultAnthropicClient,
]>,
}>,
}
}
Now, when we want to construct a specific application context, we only need to change the Code
type by using PhantomData
. This gives us a flexible, type-safe way to select the desired builder pipeline at runtime:
pub async fn main() -> Result<(), Error> {
let builder = AnthropicAndChatGptAppBuilder {
db_options: "file:./db.sqlite".to_owned(),
db_journal_mode: "WAL".to_owned(),
http_user_agent: "SUPER_AI_AGENT".to_owned(),
anthropic_key: "1234567890".to_owned(),
open_ai_key: "1234567890".to_owned(),
open_ai_model: "gpt-4o".to_owned(),
llm_preamble: "You are a helpful assistant".to_owned(),
};
let chat_gpt_app: ChatGptApp =
builder.handle(PhantomData::<BuildChatGptApp>, ()).await?;
let anthropic_app: AnthropicApp =
builder.handle(PhantomData::<BuildAnthropicApp>, ()).await?;
let combined_app: AnthropicAndChatGptApp =
builder.handle(PhantomData::<BuildAnthropicAndChatGptApp>, ()).await?;
/* Use the application contexts here */
Ok(())
}
This example highlights how CGP's DSL features are not limited to building full-fledged domain-specific languages like Hypershell. Even in this lightweight form, they are immensely valuable for labeling and routing different behaviors based on combinations of builder providers.
In essence, we are still constructing a mini-DSL, albeit one composed of simple symbolic "statements" without complex language constructs. This approach not only brings expressive power to your builder logic, but also lays the groundwork for future extensions — such as richer abstract syntaxes — using the same techniques introduced by Hypershell.
Conclusion
In this first installment, we explored how CGP v0.4.2 empowers Rust developers to construct application contexts using modular, extensible builders. You’ve seen how individual providers like BuildSqliteClient
, BuildHttpClient
, and BuildOpenAiClient
can be composed to build complex types without tight coupling or boilerplate. We’ve also demonstrated how the same context can be reused across multiple application variants — from SQLite to Postgres, from ChatGPT to Claude — all through declarative builder composition.
This approach dramatically simplifies configuration management, promotes code reuse, and opens the door to highly flexible, plugin-style architectures in Rust. Whether you're building minimal test contexts or full-featured production systems, CGP gives you the tools to scale your logic modularly and safely.
In the next part of this series, we’ll shift gears to look at extensible variants, where CGP tackles the expression problem with a modular visitor pattern. If you've ever wanted to define interpreters, pattern match over generic enums, or evolve your data types without breaking existing logic — you won’t want to miss what’s coming next.
Stay tuned!
Hire Me
P.S. Btw, I am available for hire!