4 min read

Easier generic functions over similar structs in Rust

While developing RCKIVE, I've done a development against Shopify's GraphQL APIs. I quickly found out that the dealing with GraphQL in Rust could be challenging. Generating Rust serde structs and clients from GraphQL is seamless, but GraphQL's lenient response typing doesn't combine well with Rust's strict typing. Let's illustrate the problem using graphql-client and Shopify's API:

query GetOrders {
  orders(first: 10) {
    edges {
      node {
        id
        displayFulfillmentStatus
        lineItems(first: 10) {
          edges {
            node {
              id
              customAttributes {
                key
                value
              }
            }
          }
        }
      }
    }
  }
}

With the following Rust code:

#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "schema.graphql",
    query_path = "orders_query.graphql",
)]
pub struct OrdersQuery;

That OrdersQuery struct will expand to something like this (heavily edited for readability):

struct GetOrders;
mod get_orders {
    type ID = String;
    pub enum OrderDisplayFulfillmentStatus { ... }

    pub struct ResponseData {
        pub orders: GetOrdersOrders,
    }
    pub struct GetOrdersOrders {
        pub edges: Vec<GetOrdersOrdersEdges>,
    }
    pub struct GetOrdersOrdersEdges {
        pub node: GetOrdersOrdersEdgesNode,
    }
    pub struct GetOrdersOrdersEdgesNode {
        pub id: ID,
        #[serde(rename = "displayFulfillmentStatus")]
        pub display_fulfillment_status: OrderDisplayFulfillmentStatus,
        #[serde(rename = "lineItems")]
        pub line_items: GetOrdersOrdersEdgesNodeLineItems,
    }
    pub struct GetOrdersOrdersEdgesNodeLineItems {
        pub edges: Vec<GetOrdersOrdersEdgesNodeLineItemsEdges>,
    }
    pub struct GetOrdersOrdersEdgesNodeLineItemsEdges {
        pub node: GetOrdersOrdersEdgesNodeLineItemsEdgesNode,
    }
    pub struct GetOrdersOrdersEdgesNodeLineItemsEdgesNode {
        pub id: ID,
        #[serde(rename = "customAttributes")]
        pub custom_attributes: Vec<
            GetOrdersOrdersEdgesNodeLineItemsEdgesNodeCustomAttributes,
        >,
    }
    pub struct GetOrdersOrdersEdgesNodeLineItemsEdgesNodeCustomAttributes {
        pub key: String,
        pub value: Option<String>,
    }
}

Mapping all the way down

To get an attribute from a line item, the resulting queries become deeply nested:

let orders = data.orders.edges;

for order in orders {
    let value = order
        .node
        .line_items
        .edges
        .iter()
        .flat_map(|line_item| &line_item.node.custom_attributes)
        .find_map(|attr| match attr.key.as_str() {
            "custom_attr" => attr.value.clone(),
            _ => None,
        });
}

Now imagine that you want to write a generic function that could work across multiple, similar queries. In Typescript I could easily type the function so that I accept any object that looks has the data I need. This is strict duck typing:

type EdgeNodes<T> = { edges: Array<{ node: T }> };

type HandleXArg = {
    orders: EdgeNodes<{
        line_items: EdgeNodes<{
            custom_attributes: Array<{
                key: string,
                value?: string,
            }>
        }>
    }>
};

function handleX(arg: HandleXArg) {
    // ...
}

However, Rust structs are nominatively typed. I cannot tell the type system to only need specific parts. I could use GraphQL's Fragments, but that would still require me to rewrite my queries if I want to use it in different functions.

substruct

And so I'm building substruct. Let's see some example code first:

#[derive(substructRoot)]
struct User {
    id: String,
    name: String,
    created_at: DateTime<Utc>,
}

#[substruct_child(
    root = User,
    fields(id, name, created_at),
)]
struct FunctionA;

#[substruct_child(
    root = User,
    fields(id, created_at),
)]
struct FunctionB;

#[substruct_use(
    root = User,
    fields(id, created_at),
)]
fn get_name(query: _) {
    println!("{}: {:?}", query.id(), query.created_at())
}

The get_name function now looks almost duck typed!

Under the hood

In the background, we create a getter trait for each field of the root struct. Each child struct then gets implementations for the fields it inherits. Let's look at that first[1]. We'll start with the root struct:

struct User {
    id: String,
    name: String,
    created_at: DateTime<Utc>,
}

// Getter trait for the `id` field ...
trait __User__Id {
    fn id(&self) -> &String;
}
// ... and its implementation.
impl __User__Id for User {
    fn id(&self) -> &String {
        &self.id
    }
}
// We also create a type alias, we'll get to that later.
type __User__Id__Type = String;

// `name` and `created_at` field traits hidden for brevity.

The child structs reference the field types[2] and implement the traits. For FunctionA, that looks like this:

// We generate the full struct, referencing the root's field types.
struct FunctionA {
    id: __User__Id__Type,
    name: __User__Name__Type,
    created_at: __User__CreatedAt__Type,
}

// And we implement the getter traits for each field.
impl __User__Id for FunctionA {
    fn id(&self) -> &__User__Id__Type {
        &self.id
    }
}
// Again, imagine the same for `name` and `created_at`...

When modifying a function, substruct then creates a special trait that inherits the getter traits for the required fields. It also immediately implements this trait generically for any type that implements all the getter traits[3].

trait GetNameInput: __User__Id + __User__CreatedAt {}
impl<T: __User__Id + __User__CreatedAt> GetNameInput for T {}
fn get_name(query: impl GetNameInput) {
    // ...
}

What's next?

Next up would be validating my implementation by integrating it into a GraphQL library. I might fork graphql-client to make this work in there.

To get there, there is one major problem that needs solving. Substruct doesn't yet support the use case I started out with where I want the function to be generic over a deeply nested type. I wonder if leveraging Generic Associated Types (GATs) in Rust could be the solution. This is something I'm eager to experiment with next!

I will add that I'm doing this mostly as an exercise to learn proc macros. Don't expect this to be finished, unless you feel like contributing! However, right now I'm excited to continue developing, and plan to contribute additional documentation to darling (which has been very useful).


  1. The expanded code isn't very streamlined yet, so keep in mind that this is a work in progress: ↩︎

  2. The macro cannot find the actual type directly since it only has access to the struct it's operating on. Instead, these type aliases are named in a particular way to allow their referencing with the information we have in the child structs. ↩︎

  3. Trait inheritance is unidirectional. From an old Rust forum post: "However, this does not mean that if a type extends B it will automatically extend A; trait inheritance is just a way to specify requirements, that is, trait B: A means that we can know that if some type T implements B, it also necessarily implements A." However, since our new trait doesn't introduce any unique characteristics, the generic implementation can be empty, solely relying on the other traits. ↩︎