Why can't deserialize from `Value`?

I wrote a deserialization for my structures and found an asymmetry in the parsing implementation from &str and serde_json::Value. Because of this, for a long time I could not understand why my structure is not deserialized. I got an error: data did not match any variant of untagged enum. Here is a simplified example of this, giving a different error:

use serde::Deserialize;

#[derive(Debug, Deserialize, PartialEq)]
#[serde(try_from = "&str")]
enum Variant {
    A,
    B,
}

impl<'a> TryFrom<&'a str> for Variant {
    type Error = &'a str;
    
    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
        match value {
            "a" => Ok(Self::A),
            "b" => Ok(Self::B),
            _ => Err(value),
        }
    }
}

fn main() {
    let var: Variant = serde_json::from_str("\"a\"").unwrap(); // Works fine
    assert_eq!(var, Variant::A);
    
    let var: Variant = serde_json::from_value(serde_json::json!("a")).unwrap(); // Error
    assert_eq!(var, Variant::A);
}

I write TryFrom<&str> here because it is a more general implementation than TryFrom<String>.

My questions:

  • Why does it work like that?
  • Can it be fixed so it's not confusing?

Looks like try_from = &str means that serde will deserialize into &str, which can only works with zero-copy/borrowing deserialization (it borrows directly from the input string). And serde_json::from_value requires DeserializeOwned, so they're kinda incompatible. I wonder if it'd be possible to return nicer compile time error like Variant doesn't impl DeserializeOwned instead of runtime error.

Anyway, what you can do to support both cases efficiently, is to try Cow<str> instead of &str. I've never used try_from attribute before, but it should work.

Anyway, I think you've simplified your example a bit too much.. can you share the full code? There might be a simpler fix there.

And serde_json::from_value requires DeserializeOwned

Well, Variant implements DeserializeOwned actually. So I don't see any reason why it shouldn't work in theory.

Anyway, I think you've simplified your example a bit too much.. can you share the full code? There might be a simpler fix there.

I used serde_json::from_value for unit testing. A simple solution in my case is using serde_json::from_str.

You can deserialize from a Value while also borrowing, if you change your code to
let var = Variant::deserialize(&serde_json::json!("a")).unwrap();.

To fix the actual problem you need to avoid creating a &str in the first place. The smaller change is using #[serde(try_from = "String")]. Cow<str> should work too, but it always creates an owned version, so using String is simpler.

The proper way for 0-copy deserialization requires a Visitor somewhere, for example by implementing Deserialize.

impl<'de> Deserialize<'de> for Variant2 {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        struct Helper;
        impl<'de> Visitor<'de> for Helper {
            type Value = Variant2;
            fn expecting(&self, _: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
                todo!()
            }

            fn visit_str<E: Error>(self, s: &str) -> Result<Self::Value, E> {
                match s {
                    "a" => Ok(Variant2::A),
                    "b" => Ok(Variant2::B),
                    _ => Err(E::unknown_variant(s, &["a", "b"])),
                }
            }
        }

        deserializer.deserialize_str(Helper)
    }
}

If you are willing to rely on other crates, you can also use a different derive (e.g., serde_with::DeserializeFromStr) and use FromStr instead of TryFrom<&str>.

2 Likes

Yeah, I was a little bit imprecise, sorry. What I meant it does indeed implement DeserializeOwned, but it only supports visit_borrowed_str (and not visit_str or visit_string), which makes it unusable in DeserializeOwned scenarios.

Thanks for the help, now it's working!
I wrote the implementation manually. Still, I would like to just work with derive. I hope serde can be improved in this way.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.