Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do you deserialise JSON in Rust without coupling

Tags:

rust

serde

I'm very new to Rust. I'm trying to write a JSON wrapper (trait) so that callers across my program are not coupled to a specific implementation, but can instead require the trait to be Dependency Injected. DI is a non-negotiable for any code I write, fyi.

This is my trait:

use crate::utils::traits::custom_errors::json::TJsonError as TCustomJsonError;

pub trait TJsonParser {
  fn parse<T>(&self, json_string: &str) -> Result<T, Box<dyn TCustomJsonError>>;
}

And here's my implementation so far, for which I'm trying serde:

use serde_json;
// other imports removed for brevity;

pub struct JsonParser;

impl TJsonParser for JsonParser {
    fn parse<T>(&self, json_string: &str) -> Result<T, Box<dyn TCustomJsonError>> {
        match serde_json::from_str(json_string) {
            Ok(value) => Ok(value),
            Err(err) => Err(Box::new(CustomJsonError(format!("Error parsing JSON: {}", err)))),
        }
    }
}

The problem I'm facing is the compiler complains about serde_json::from_str(json_string) saying:

the trait bound T: Deserialize<'_> is not satisfied. Deserialize<'_> is not implemented for T

In C# for example, I can do this:

public interface IJsonParser
{
    T Parse<T>(string jsonString);
}
// ...

using NewtonsoftJsonConvert = Newtonsoft.Json.JsonConvert;
public class JsonParser : IJsonParser
{
    public T Parse<T>(string jsonString)
    {
        T value = NewtonsoftJsonConvert.DeserializeObject<T>(jsonString);
        return value;
    }
}

It's not simply because C# supports reflection that this is possible. Typescript does not have reflection, but I can still do this:

interface IJsonParser {
  Parse<T>(jsonString: string): T
}

class JsonParser implements IJsonParser {
  Parse<T>(jsonString: string): T {
    let parsed_obj = JSON.parse(jsonString)
    // Use validation library to validate parsed_obj as T and return as T.
  }
}

In either language, I can define a wrapper abstraction without having to put constraints (attributes, bounds, call it what you may) on the generic parameters or my domain types (structs, interfaces, types, call it what you may).

I suspect this whole issue arises from serde being inflexible by forcing type constraints.

Questions:

  1. Maybe I'm mistaken, but I feel like I need to specify a where trait bound in the definition for parse and then attribute all my structs that need to be deserialised to, with #[derive(Deserialize)]. Is this correct?
  2. If the answer to #1 is (more or less) "yes", then how can I create a wrapper such that I do not need to attribute my domain structs with implementation (serde or such) details or reference implementation when specifying bounds in my trait definition? This is the whole point of creating a wrapper to begin with.
like image 667
Ash Avatar asked Oct 23 '25 15:10

Ash


1 Answers

First off, I just want to say that Rust is not going to perfectly fit your use case. While I am going to propose a solution, keep in mind there are 2 big drawbacks that will be difficult to overcome.

Generally, the key point I want to emphasize is that in every language you will be coupling with something to perform serialization/deserialization. In most languages, JSON parsing is implemented by coupling to core language or runtime features (generally some sort of reflection). Rust does not provide this, so you must find something else to couple to instead. People are willing to couple their code to serde since it is seen as one of the most stable/reliable crates within the entire Rust ecosystem. Personally, I recommend biting the bullet and coupling with serde, but I do also go through a different approach.

Approach

I think the core issue with the code you attempted is where you put the generic. If you put the generic inside the trait you run into the issues you encountered were you can not impose additional bounds on T within different implementations. In Rust we instead want to raise the generic to the trait itself so we can pick and choose what types it gets implemented for. This also solves a common issue with OOP languages where it is easy to hide interface requirements behind layers of abstraction. Here is an example of roughly what I am proposing.

pub trait TJsonParser<T> {
    type Error: TCustomJsonError;

    fn parse(&self, json_string: &str) -> Result<T, Self::Error>;
}

trait TCustomJsonError {
    // etc.
}

Going into this, we know that some types simply will not be deserializable by all TJsonParsers. For example, this might include types imported via FFI that can only be handled by SpecialFfiJsonParser. We need to provide some way of telling the compiler which types we can and can't work with for static analysis to actually help us. If we want to add a parser for serde_json, we would write it like this.

use serde::de::DeserializeOwned;

pub struct JsonParser;

impl<T: DeserializeOwned> TJsonParser<T> for JsonParser {
    type Error = serde_json::Error;

    fn parse(&self, json_string: &str) -> Result<T, Self::Error> {
        serde_json::from_str(json_string)
    }
}

impl TCustomJsonError for serde_json::Error {}

In effect, we are really just making a wrapper for serde::Deserialize that lets us decouple the code from our approach.

Flaws

How do we get type-specific information?

The biggest flaw that I think some of the commenters were getting at is that no matter what approach you use, you need to have some way of conveying information about the type being deserialized to the parser. In other languages, this is done by coupling the implementation with the language or runtime itself (usually via reflection). We don't normally think about this as coupling since it normally is not possible for this coupling to break. In the case of Rust, we do not have a system provided by the language to couple to, so we need to use something else.

This is where we run into issues. Broadly speaking, we need some sort of trait to depend on (ideally one that we can derive on types as needed). In the case of serde, this comes in the form of Serialize and Deserialize. However, we also have other choices like crates that derive traits for performing general reflection. However, regardless of what you choose you need to couple to something. Many projects/libraries choose to couple to serde as it is seen as one of the most stable packages within the entire Rust ecosystem.

In an attempt to break this coupling, we could do the same thing we do everywhere else and create a wrapper. One way to do this would be to make your own derive proc macro for JsonParsable types.

This works in part because there is no restriction that JsonParsable derive a single trait implementation or that the traits being derived even be called JsonParsable. For example, take this code.

/// Depending on the requirements of parsers the given structure, this could
/// derive `serde::Deserialize` and `other_library::Deserialize`.
#[derive(JsonParsable)]
struct Foo { /* etc. */ }

/// Perhaps you have some issues with `serde`, so this type can only derive
/// `other_library::Deserialize`.
#[derive(JsonParsable)]
struct Bar { /* etc. */ }

A necessary part of implementing TJsonParser would be updating JsonParsable to derive any additional trait requirements. In this approach you would be coupling to JsonParsable, but at least it is your own library so it is less of an issue. I do not think there is any way to distribute parser-specific derives outside of JsonParsable without making your own build system wrapper. However, I highly advise against this as you would just be moving the coupling into your build system and likely introducing numerous anti-patterns in the process.

Exploding trait dependencies

The other issue you will encounter is trait dependencies piling up. I think the easiest way to express this is with an example.

fn handle_foo<J>(parser: &J)
    where J: TJsonParser<Foo> { /* etc. */ }

fn handle_bar<J>(parser: &J)
    where J: TJsonParser<Bar> { /* etc. */ }

fn handle_baz<J>(parser: &J)
    where J: TJsonParser<Baz> { /* etc. */ }

/// Handles Foo, Bar, and Baz requests
fn handle_request<J>(parser: &J)
    where J: TJsonParser<Foo> + TJsonParser<Bar> + TJsonParser<Baz>,
{ /* etc. */ }

If you want to implement a web server, it is easy to imagine that there will end up being a ton of TJsonParser<...> requirements piling up on every generic function going up the dependency tree. One way we can mitigate this is with wrapper traits, but it is more of a bandaid than a solution.

/// We can now use RequestJsonParser as a stand in for this group of requirements.
pub trait RequestJsonParser: TJsonParser<Foo> + TJsonParser<Bar> + TJsonParser<Baz> {}

impl<T> RequestJsonParser for T
    where T: TJsonParser<Foo> + TJsonParser<Bar> + TJsonParser<Baz> {}

/// Handles Foo, Bar, and Baz requests
fn handle_request<J>(parser: &J)
    where J: RequestJsonParser,
{ /* etc. */ }

There are RFCs in the works that would improve this issue (such as requiring a trait map 1-1 with another trait; for<B: Sized> T: TJsonParser<B>), but don't expect anything soon.

like image 87
Locke Avatar answered Oct 26 '25 04:10

Locke



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!