serde icon indicating copy to clipboard operation
serde copied to clipboard

Add ability to use `#[serde(default)]` to deserialize internally tagged enums when no tag is provided

Open harrisonturton opened this issue 2 years ago • 6 comments

This PR addresses #2231 and #1799

Summary

This PR adds support for "default enum variants". This allows serde to fallback to a specific type when it encounters a tagged enum, but no tag. This allows the following enum:

#[derive(Deserialize)]
#[serde(tag = "type")]
pub enum Test {
  #[serde(default)]
  One {
    foo: String,
    bar: String,
  },
  Two {
    baz: String,
    qux: String,
  }
}

To be deserialized from the JSON string (using serde_json)

{ "foo": "...", "bar": "..." }

Note that it has no "tag" field despite the #[serde(tag = "type")] attribute.

Alternatives

This could also be structured as an attribute on the enum itself, like:

#[derive(Deserialize)]
#[serde(tagged)]
#[serde(default_tag = "One")]
pub enum Test {
  One { ... },
  Two { ... },
}

But I opted to use #[serde(default)] field attribute because it matches the native Rust #[default] field attribute. It also makes it trivial to support attributes that transform the tags, like rename and rename_all.

However, this could be confusing because it overloads the meaning of #[serde(default)] which is also used to create default values for fields. I think this is fine since it's only used for enum variants, but this is potentially a good reason to use the "enum attribute" default_tag = "..." instead.

harrisonturton avatar Jun 14 '23 08:06 harrisonturton

Ah, I've just found that a request for this feature was rejected last year for being an uncommon use-case.

We ran into this recently (hence this patch), and others ran into it in #1799, #2231 and #1410. This feature is very helpful for evolving a message into a sum type in a backwards compatible way.

For example, allowing GetUserRequestV2 to replace GetUserRequestV1 without any compatibility issues:

#[derive(Deserialize)]
struct GetUserRequestV1 {
  user_id: String
}

#[derive(Deserialize)]
#[serde(tag = "type")]
enum GetUserRequestV2 {
  #[serde(default)]
  GetUserById { user_id: String },
  GetUserByEmail { email: String },
}

This is possible today, but afaict it requires workarounds that need quite a bit of boilerplate, rely on an undocumented feature, or use a custom derive implementation.

Is this worth reconsidering?

harrisonturton avatar Jun 16 '23 07:06 harrisonturton

@dtolnay would love to help get this feature over the line. WDYT of these changes/approach?

harrisonturton avatar Jul 24 '23 23:07 harrisonturton

Hi @dtolnay, I'd be really interested in utilizing the behavior implemented by this PR - it really simplifies a large scale API migration I need to perform (and it seems like functionality a lot of people have been clamoring for). Was just wondering if you'd seen this PR, I noticed it hadn't had any activity in over a month?

MaxwellBo avatar Aug 17 '23 00:08 MaxwellBo

Can this be merged, please?

avkonst avatar Oct 04 '23 01:10 avkonst

Found this merge request looking for it. Currently there are ways to jump through hoops (an untagged enum that matches first to a tagged enum and then parses to an untagged one) to achieve this behavior, but they are unclear compared to the method proposed here.

Is there an update on merging of this PR?

jamuraa avatar Jan 17 '24 16:01 jamuraa

@harrisonturton what happens when an unrecognized tag is provided? Does it resolve to the default, or does it error? Could it error?

MaxwellBo avatar Jan 17 '24 23:01 MaxwellBo