Modelling an external JSON API with the Rust type system

Hey there,

I'm trying to model an external API (Slack) that receives JSON in a "smart" way with the typesystem. Many advantages to using this over, say, serde's json!.

I've tried a couple of implementations of varying types, but I'm yet to find anything that has nice ergonomics, and is fully checked at compile time, and completely models the target API. Let me run you through what I've tried.

The Enum-Struct

This method makes use of some serde features to tag the enum fields, so "feels" pretty good. There are a couple of issues.

Here's an 'example' implementation and usage.

enum Text {
  PlainText { text: String },
  MarkDown { text: String }
}

enum Block {
  Action {
    text: Text,
    url: Option<String>,
  }
}

let b = Block::Action {
  text: Text::PlainText { text: String::from("something") },
  url: None,
}

Now, this isn't too bad. It's pretty nice to write as a declarative API. The problematic aspects are:

  • You must provide None for non-required options
    • I know I can implement Default, and the do ..Default::default(), but I want the type system to enforce people passing in the required types, and as far as I can tell, the default implementation must return all the fields. If that's wrong, that will go a long way to fixing this.
  • It can't model the fact that some items require, for example, only Text::PlainText instead of Text as a whole.

The Enum and the Struct

I can fix that last point, somewhat, by doing:

struct PlainText { text: String }
struct MarkDown { text: String }

enum Text { PlainText(PlainText), MarkDown(MarkDown) }

The downside is that now if something requires Text, I must do

let text: Text = Text::MarkDown(MarkDown { text: String::from("text") });

I can implement From, which makes it slightly nicer, but the ergonomics of the struct instantiation are less good:

// impl From<MarkDown> for Text { ... }
let text: Text = (MarkDown { text: String::from("text") }).into()

This also still has the problem of having to provide all the Optional fields.

The Builder Pattern

The other thing I tried was the builder pattern. This kinda extends from the previous one, and fixes the problem of having to provide options for non-required items. It can use into even more to make the ergonomics even nicer (don't need to do String::from because the builder can do the into for you.

// Implementation of the builder not shown
let text: Text = MarkDown::init().text("text").build().into();

// Also works nicely with more complicated builds,
// because the `into` can be implicit:
let action: Block = Action::init()
  .text(MarkDown::init.text("text").build())
  .build();

And the Option can be auto-set when calling .build() if not already set. It's also really nice because users don't have to do Some(value) - the builder method can accept value and wrap it into Some for the user.

The downside to this approach is that there is no way (that I know of) for the compiler to enforce that some methods are called. This means that you need a runtime error in the build method if the required fields aren't passed in. I want to avoid this, because it misses a lot of the niceties of the type system knowing what fields are present in the first place.

In an ideal world, the declarative approach of the first option, with the impl From of the second option, and the auto-default + auto-into of the third approach would be perfect. But, as far as I can tell, that's not possible. If you've got ideas of how you would go about implementing something like this, that'd be awesome.

Check the typed-builder crate, it's what I use for a similar use case and it gives (a slightly awkward) compiler error if you missed any required fields.

This is certainly possible!

struct Builder {
    field_a: Option<String>,
}
struct Builder2 {
    field_a: Option<String>,
    field_b: String,
}
struct Output {
    field_a: Option<String>,
    field_b: String,
}

impl Builder {
    fn new() -> Self {
        Builder {
            field_a: None,
        }
    }
    fn field_a(self, a: String) -> Self {
        Self {
            field_a: Some(a),
        }
    }
    fn field_b(self, b: String) -> Builder2 {
        Builder2 {
            field_a: self.field_a,
            field_b: b,
        }
    }
}

impl Builder2 {
    fn field_a(self, a: String) -> Self {
        Self {
            field_a: Some(a),
            field_b: self.field_b,
        }
    }
    fn build(self) -> Output {
        Output {
            field_a: self.field_a,
            field_b: self.field_b,
        }
    }
}

The builder above enforces at compile time that field_b is supplied, but field_a does not have to.

1 Like

This is what I ended up doing after a lot of Googling :slight_smile: