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.
- I know I can implement
- It can't model the fact that some items require, for example, only
Text::PlainText
instead ofText
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 Option
al 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.