Repository: GREsau/schemars
Pull request #437 (2025-06-22 08:08:52)

Add `_unstable_ref_variants` attribute
Addresses #39 #157 #201 #390

It's "unstable" until I get some feedback about whether this solves the problem correctly. Some things that need deciding (some of these can be implemented after stabilisation if necessary):
- Naming - is `ref_variants` clear enough?
- Should trivial unit variants be inlined regardless of this attribute? (currently, they're not)
- Allow individual variants to opt in to being `$ref`'d, instead of the entire enum?
- Allow individual variants to opt back in to being inlined when the enum has this attribute?
- Currently, the variant name is used as the schema name - should the enum's name be prepended to this? Should it be configurable?


Patch:
diff --git a/schemars_derive/src/ast/from_serde.rs b/schemars_derive/src/ast/from_serde.rs
index 51521d5..7b18ed2 100644
--- a/schemars_derive/src/ast/from_serde.rs
+++ b/schemars_derive/src/ast/from_serde.rs
@@ -20,7 +20,7 @@ impl<'a> FromSerde for Container<'a> {
 
     fn from_serde(errors: &Ctxt, serde: Self::SerdeType) -> Self {
         let data = Data::from_serde(errors, serde.data);
-        let attrs = ContainerAttrs::new(&serde.original.attrs, errors);
+        let attrs = ContainerAttrs::new(&serde.original.attrs, &data, errors);
         let rename_type_params = match &attrs.rename_format_string {
             Some(s) => crate::name::get_rename_format_type_params(errors, s, serde.generics),
             None => BTreeSet::new(),
diff --git a/schemars_derive/src/attr/mod.rs b/schemars_derive/src/attr/mod.rs
index 4a71774..e3b4c97 100644
--- a/schemars_derive/src/attr/mod.rs
+++ b/schemars_derive/src/attr/mod.rs
@@ -16,6 +16,7 @@ use syn::spanned::Spanned;
 use syn::{Attribute, Expr, ExprLit, Ident, Lit, LitStr, Path, Type};
 use validation::ValidationAttrs;
 
+use crate::ast::Data;
 use crate::idents::SCHEMA;
 
 pub use custom_meta::*;
@@ -48,6 +49,7 @@ pub struct ContainerAttrs {
     // because it depends on the type's generic params.
     pub rename_format_string: Option<LitStr>,
     pub inline: bool,
+    pub ref_variants: bool,
     pub with: Option<WithAttr>,
 }
 
@@ -294,19 +296,19 @@ impl FieldAttrs {
 }
 
 impl ContainerAttrs {
-    pub fn new(attrs: &[Attribute], cx: &Ctxt) -> Self {
+    pub fn new(attrs: &[Attribute], data: &Data, cx: &Ctxt) -> Self {
         let mut result = Self::default();
-        result.populate(attrs, cx);
+        result.populate(attrs, data, cx);
         result
     }
 
-    fn populate(&mut self, attrs: &[Attribute], cx: &Ctxt) {
+    fn populate(&mut self, attrs: &[Attribute], data: &Data, cx: &Ctxt) {
         let schemars_cx = &mut AttrCtxt::new(cx, attrs, "schemars");
         let serde_cx = &mut AttrCtxt::new(cx, attrs, "serde");
 
         self.common.populate(attrs, schemars_cx, serde_cx);
-        self.process_attr(schemars_cx);
-        self.process_attr(serde_cx);
+        self.process_attr(data, schemars_cx);
+        self.process_attr(data, serde_cx);
 
         self.repr = attrs
             .iter()
@@ -314,14 +316,15 @@ impl ContainerAttrs {
             .and_then(|a| a.parse_args().ok());
     }
 
-    fn process_attr(&mut self, cx: &mut AttrCtxt) {
-        cx.parse_meta(|m, n, c| self.process_meta(m, n, c));
+    fn process_attr(&mut self, data: &Data, cx: &mut AttrCtxt) {
+        cx.parse_meta(|m, n, c| self.process_meta(m, n, data, c));
     }
 
     fn process_meta(
         &mut self,
         meta: CustomMeta,
         meta_name: &str,
+        data: &Data,
         cx: &AttrCtxt,
     ) -> Result<(), CustomMeta> {
         match meta_name {
@@ -358,6 +361,19 @@ impl ContainerAttrs {
                 }
             },
 
+            "_unstable_ref_variants" if cx.attr_type == "schemars" => {
+                if !matches!(data, Data::Enum(_)) {
+                    cx.error_spanned_by(
+                        meta.path(),
+                        "`_unstable_ref_variants` can only be used on enums",
+                    );
+                } else if self.ref_variants {
+                    cx.duplicate_error(&meta);
+                } else if require_path_only(&meta, cx).is_ok() {
+                    self.ref_variants = true;
+                }
+            }
+
             _ => return Err(meta),
         }
 
diff --git a/schemars_derive/src/schema_exprs.rs b/schemars_derive/src/schema_exprs.rs
index 6a2fac1..2f1a379 100644
--- a/schemars_derive/src/schema_exprs.rs
+++ b/schemars_derive/src/schema_exprs.rs
@@ -276,8 +276,9 @@ fn expr_for_external_tagged_enum<'a>(
     variants: impl Iterator<Item = &'a Variant<'a>>,
     deny_unknown_fields: bool,
 ) -> SchemaExpr {
-    let (unit_variants, complex_variants): (Vec<_>, Vec<_>) =
-        variants.partition(|v| v.is_unit() && v.attrs.is_default() && !v.serde_attrs.untagged());
+    let (unit_variants, complex_variants): (Vec<_>, Vec<_>) = variants.partition(|v| {
+        v.is_unit() && v.attrs.is_default() && !v.serde_attrs.untagged() && !cont.attrs.ref_variants
+    });
     let add_unit_names = unit_variants.iter().map(|v| {
         let name = v.name();
         v.with_contract_check(quote! {
@@ -335,7 +336,7 @@ fn expr_for_external_tagged_enum<'a>(
         (Some(variant), schema_expr)
     }));
 
-    variant_subschemas(true, schemas)
+    variant_subschemas(cont, true, schemas)
 }
 
 fn expr_for_internal_tagged_enum<'a>(
@@ -363,7 +364,7 @@ fn expr_for_internal_tagged_enum<'a>(
         })
         .collect();
 
-    variant_subschemas(true, variant_schemas)
+    variant_subschemas(cont, true, variant_schemas)
 }
 
 fn expr_for_untagged_enum<'a>(
@@ -382,7 +383,7 @@ fn expr_for_untagged_enum<'a>(
 
     // Untagged enums can easily have variants whose schemas overlap; rather
     // that checking the exclusivity of each subschema we simply us `any_of`.
-    variant_subschemas(false, schemas)
+    variant_subschemas(cont, false, schemas)
 }
 
 fn expr_for_adjacent_tagged_enum<'a>(
@@ -458,12 +459,13 @@ fn expr_for_adjacent_tagged_enum<'a>(
         })
         .collect();
 
-    variant_subschemas(true, schemas)
+    variant_subschemas(cont, true, schemas)
 }
 
 /// Callers must determine if all subschemas are mutually exclusive. The current behaviour is to
 /// assume that variants are mutually exclusive except for untagged enums.
 fn variant_subschemas(
+    cont: &Container,
     mut unique: bool,
     schemas: Vec<(Option<&Variant>, SchemaExpr)>,
 ) -> SchemaExpr {
@@ -475,11 +477,15 @@ fn variant_subschemas(
     }
 
     let keyword = if unique { "oneOf" } else { "anyOf" };
-    let add_schemas = schemas.into_iter().map(|(v, s)| {
+    let add_schemas = schemas.into_iter().map(|(variant, mut schema)| {
+        if cont.attrs.ref_variants {
+            schema = enum_ref_variants(cont, variant, schema);
+        }
+
         let add = quote! {
-            enum_values.push(#s.to_value());
+            enum_values.push(#schema.to_value());
         };
-        match v {
+        match variant {
             Some(v) => v.with_contract_check(add),
             None => add,
         }
@@ -499,6 +505,52 @@ fn variant_subschemas(
     .into()
 }
 
+fn enum_ref_variants(cont: &Container, variant: Option<&Variant>, expr: SchemaExpr) -> SchemaExpr {
+    let Some(variant) = variant else {
+        return expr;
+    };
+
+    let cont_name = &cont.ident;
+    // FIXME can this use serialize name where appropriate?
+    let variant_name = variant.serde_attrs.name().deserialize_name();
+    let (impl_generics, ty_generics, where_clause) = cont.generics.split_for_impl();
+
+    let type_def = quote! {
+        struct _SchemarsRefVariant<T: ?::core::marker::Sized>(::core::marker::PhantomData<T>);
+
+        impl #impl_generics schemars::JsonSchema for _SchemarsRefVariant<#cont_name #ty_generics> #where_clause {
+            fn inline_schema() -> bool {
+                false
+            }
+
+            fn schema_name() -> schemars::_private::alloc::borrow::Cow<'static, str> {
+                schemars::_private::alloc::borrow::Cow::Borrowed(#variant_name)
+            }
+
+            fn schema_id() -> schemars::_private::alloc::borrow::Cow<'static, str> {
+                schemars::_private::alloc::borrow::Cow::Owned(
+                    schemars::_private::alloc::format!(
+                        "_SchemarsRefVariant/{}::{}",
+                        <#cont_name #ty_generics as schemars::JsonSchema>::schema_id(),
+                        #variant_name,
+                ))
+            }
+
+            fn json_schema(#GENERATOR: &mut schemars::SchemaGenerator) -> schemars::Schema {
+                #expr
+            }
+        }
+    };
+
+    let mut expr = SchemaExpr::from(
+        quote!(#GENERATOR.subschema_for::<_SchemarsRefVariant::<#cont_name #ty_generics>>()),
+    );
+
+    expr.definitions.push(type_def);
+
+    expr
+}
+
 // This function is also used for tagged variants, in which case the resulting SchemaExpr will be
 // embedded or mutated to include the tag. `is_actually_untagged` will be true if the enum or the
 // variant has the `untagged` attribute.
@@ -736,7 +788,9 @@ fn field_default_expr(field: &Field, container_has_default: bool) -> Option<Toke
                 }
             }
         }
-    } else { quote!(Some(#default_expr)) };
+    } else {
+        quote!(Some(#default_expr))
+    };
 
     Some(if let Some(ser_with) = field.serde_attrs.serialize_with() {
         quote! {


Test patch:
diff --git a/schemars/tests/integration/enums_ref_variants.rs b/schemars/tests/integration/enums_ref_variants.rs
new file mode 100644
index 0000000..6aa73e9
--- /dev/null
+++ b/schemars/tests/integration/enums_ref_variants.rs
@@ -0,0 +1,326 @@
+use crate::prelude::*;
+use std::collections::BTreeMap;
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+struct UnitStruct;
+
+#[derive(JsonSchema, Deserialize, Serialize, Default)]
+struct Struct {
+    foo: i32,
+    bar: bool,
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+#[schemars(_unstable_ref_variants)]
+enum External {
+    UnitOne,
+    StringMap(BTreeMap<String, String>),
+    UnitStructNewType(UnitStruct),
+    StructNewType(Struct),
+    Struct {
+        foo: i32,
+        bar: bool,
+    },
+    Tuple(i32, bool),
+    UnitTwo,
+    #[serde(with = "unit_variant_as_u64")]
+    #[schemars(with = "u64")]
+    UnitAsInt,
+    #[serde(with = "tuple_variant_as_str")]
+    #[schemars(schema_with = "tuple_variant_as_str::json_schema")]
+    TupleAsStr(i32, bool),
+}
+
+impl External {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                foo: 123,
+                bar: true,
+            },
+            Self::Tuple(456, false),
+            Self::UnitTwo,
+            Self::UnitAsInt,
+            Self::TupleAsStr(789, true),
+        ]
+    }
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(tag = "tag")]
+#[schemars(_unstable_ref_variants)]
+enum Internal {
+    UnitOne,
+    StringMap(BTreeMap<String, String>),
+    UnitStructNewType(UnitStruct),
+    StructNewType(Struct),
+    Struct { foo: i32, bar: bool },
+    // Internally-tagged enums don't support tuple variants
+    //  Tuple(i32, bool),
+    UnitTwo,
+    // Internally-tagged enum variants don't support non-object "payloads"
+    //  #[serde(with = "unit_variant_as_u64")]
+    //  #[schemars(with = "u64")]
+    //  UnitAsInt,
+    // Internally-tagged enums don't support tuple variants
+    //  #[serde(with = "tuple_variant_as_str")]
+    //  #[schemars(schema_with = "tuple_variant_as_str::json_schema")]
+    //  TupleAsStr(i32, bool),
+}
+
+impl Internal {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                foo: 123,
+                bar: true,
+            },
+            // Self::Tuple(456, false),
+            Self::UnitTwo,
+            // Self::UnitAsInt,
+            // Self::TupleAsStr(789, true),
+        ]
+    }
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(tag = "tag", content = "content")]
+#[schemars(_unstable_ref_variants)]
+enum Adjacent {
+    UnitOne,
+    StringMap(BTreeMap<String, String>),
+    UnitStructNewType(UnitStruct),
+    StructNewType(Struct),
+    Struct {
+        foo: i32,
+        bar: bool,
+    },
+    Tuple(i32, bool),
+    UnitTwo,
+    #[serde(with = "unit_variant_as_u64")]
+    #[schemars(with = "u64")]
+    UnitAsInt,
+    #[serde(with = "tuple_variant_as_str")]
+    #[schemars(schema_with = "tuple_variant_as_str::json_schema")]
+    TupleAsStr(i32, bool),
+}
+
+impl Adjacent {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                foo: 123,
+                bar: true,
+            },
+            Self::Tuple(456, false),
+            Self::UnitTwo,
+            Self::UnitAsInt,
+            Self::TupleAsStr(789, true),
+        ]
+    }
+}
+
+#[derive(JsonSchema, Deserialize, Serialize)]
+#[serde(untagged)]
+#[schemars(_unstable_ref_variants)]
+enum Untagged {
+    UnitOne,
+    StringMap(BTreeMap<String, String>),
+    UnitStructNewType(UnitStruct),
+    StructNewType(Struct),
+    Struct {
+        foo: i32,
+        bar: bool,
+    },
+    Tuple(i32, bool),
+    UnitTwo,
+    #[serde(with = "unit_variant_as_u64")]
+    #[schemars(with = "u64")]
+    UnitAsInt,
+    #[serde(with = "tuple_variant_as_str")]
+    #[schemars(schema_with = "tuple_variant_as_str::json_schema")]
+    TupleAsStr(i32, bool),
+}
+
+impl Untagged {
+    fn values() -> impl IntoIterator<Item = Self> {
+        [
+            Self::UnitOne,
+            Self::StringMap(
+                [("hello".to_owned(), "world".to_owned())]
+                    .into_iter()
+                    .collect(),
+            ),
+            Self::UnitStructNewType(UnitStruct),
+            Self::StructNewType(Struct {
+                foo: 123,
+                bar: true,
+            }),
+            Self::Struct {
+                foo: 123,
+                bar: true,
+            },
+            Self::Tuple(456, false),
+            Self::UnitTwo,
+            Self::UnitAsInt,
+            Self::TupleAsStr(789, true),
+        ]
+    }
+}
+
+mod unit_variant_as_u64 {
+    pub(super) fn serialize<S>(ser: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        ser.serialize_u64(42)
+    }
+
+    pub(super) fn deserialize<'de, D>(deser: D) -> Result<(), D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use serde::de::Deserialize;
+
+        u64::deserialize(deser).map(|_| ())
+    }
+}
+
+mod tuple_variant_as_str {
+    pub(super) fn serialize<S>(i: &i32, b: &bool, ser: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        ser.collect_str(&format_args!("{i} {b}"))
+    }
+
+    pub(super) fn deserialize<'de, D>(deser: D) -> Result<(i32, bool), D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use serde::de::{Deserialize, Error};
+        let error = || Error::custom("invalid string");
+
+        let (i, b) = <&str>::deserialize(deser)?
+            .split_once(' ')
+            .ok_or_else(error)?;
+
+        Ok((
+            i.parse().map_err(|_| error())?,
+            b.parse().map_err(|_| error())?,
+        ))
+    }
+
+    pub(super) fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
+        schemars::json_schema!({
+            "type": "string",
+            "pattern": r"^\d+ (true|false)$"
+        })
+    }
+}
+
+#[test]
+fn externally_tagged_enum() {
+    test!(External)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(External::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
+
+#[test]
+fn internally_tagged_enum() {
+    test!(Internal)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(Internal::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
+
+#[test]
+fn adjacently_tagged_enum() {
+    test!(Adjacent)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(Adjacent::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
+
+#[test]
+fn untagged_enum() {
+    test!(Untagged)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip(Untagged::values())
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
+
+#[derive(JsonSchema, Serialize, Deserialize)]
+#[schemars(_unstable_ref_variants)]
+enum NoVariants {}
+
+#[test]
+fn no_variants() {
+    test!(NoVariants)
+        .assert_snapshot()
+        .assert_rejects_de(arbitrary_values());
+}
+
+#[derive(JsonSchema, Serialize, Deserialize)]
+#[serde(rename_all_fields = "UPPERCASE", rename_all = "snake_case")]
+#[schemars(_unstable_ref_variants)]
+enum Renamed {
+    StructVariant {
+        field: String,
+    },
+    #[serde(rename = "custom name variant")]
+    RenamedStructVariant {
+        #[serde(rename = "custom name field")]
+        field: String,
+    },
+}
+
+#[test]
+fn renamed() {
+    test!(Renamed)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip([
+            Renamed::StructVariant {
+                field: "foo".to_owned(),
+            },
+            Renamed::RenamedStructVariant {
+                field: "bar".to_owned(),
+            },
+        ])
+        .assert_rejects_de(arbitrary_values());
+}
diff --git a/schemars/tests/integration/main.rs b/schemars/tests/integration/main.rs
index d870aaa..1accce0 100644
--- a/schemars/tests/integration/main.rs
+++ b/schemars/tests/integration/main.rs
@@ -20,6 +20,7 @@ mod enum_repr;
 mod enums;
 mod enums_deny_unknown_fields;
 mod enums_flattened;
+mod enums_ref_variants;
 mod enums_untagged_variant;
 mod examples;
 mod extend;
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~adjacently_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~adjacently_tagged_enum.json
new file mode 100644
index 0000000..a1c3f4b
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~adjacently_tagged_enum.json
@@ -0,0 +1,220 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Adjacent",
+  "oneOf": [
+    {
+      "$ref": "#/$defs/UnitOne"
+    },
+    {
+      "$ref": "#/$defs/StringMap"
+    },
+    {
+      "$ref": "#/$defs/UnitStructNewType"
+    },
+    {
+      "$ref": "#/$defs/StructNewType"
+    },
+    {
+      "$ref": "#/$defs/Struct2"
+    },
+    {
+      "$ref": "#/$defs/Tuple"
+    },
+    {
+      "$ref": "#/$defs/UnitTwo"
+    },
+    {
+      "$ref": "#/$defs/UnitAsInt"
+    },
+    {
+      "$ref": "#/$defs/TupleAsStr"
+    }
+  ],
+  "$defs": {
+    "UnitOne": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitOne"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    "StringMap": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "StringMap"
+        },
+        "content": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "string"
+          }
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "UnitStructNewType": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitStructNewType"
+        },
+        "content": {
+          "$ref": "#/$defs/UnitStruct"
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "UnitStruct": {
+      "type": "null"
+    },
+    "StructNewType": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "StructNewType"
+        },
+        "content": {
+          "$ref": "#/$defs/Struct"
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    },
+    "Struct2": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "Struct"
+        },
+        "content": {
+          "type": "object",
+          "properties": {
+            "foo": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "bar": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "foo",
+            "bar"
+          ]
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "Tuple": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "Tuple"
+        },
+        "content": {
+          "type": "array",
+          "prefixItems": [
+            {
+              "type": "integer",
+              "format": "int32"
+            },
+            {
+              "type": "boolean"
+            }
+          ],
+          "minItems": 2,
+          "maxItems": 2
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "UnitTwo": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitTwo"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    "UnitAsInt": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitAsInt"
+        },
+        "content": {
+          "type": "integer",
+          "format": "uint64",
+          "minimum": 0
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    },
+    "TupleAsStr": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "TupleAsStr"
+        },
+        "content": {
+          "type": "string",
+          "pattern": "^\\d+ (true|false)$"
+        }
+      },
+      "required": [
+        "tag",
+        "content"
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~externally_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~externally_tagged_enum.json
new file mode 100644
index 0000000..fa011cc
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~externally_tagged_enum.json
@@ -0,0 +1,176 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "External",
+  "oneOf": [
+    {
+      "$ref": "#/$defs/unitOne"
+    },
+    {
+      "$ref": "#/$defs/stringMap"
+    },
+    {
+      "$ref": "#/$defs/unitStructNewType"
+    },
+    {
+      "$ref": "#/$defs/structNewType"
+    },
+    {
+      "$ref": "#/$defs/struct"
+    },
+    {
+      "$ref": "#/$defs/tuple"
+    },
+    {
+      "$ref": "#/$defs/unitTwo"
+    },
+    {
+      "$ref": "#/$defs/unitAsInt"
+    },
+    {
+      "$ref": "#/$defs/tupleAsStr"
+    }
+  ],
+  "$defs": {
+    "unitOne": {
+      "type": "string",
+      "const": "unitOne"
+    },
+    "stringMap": {
+      "type": "object",
+      "properties": {
+        "stringMap": {
+          "type": "object",
+          "additionalProperties": {
+            "type": "string"
+          }
+        }
+      },
+      "required": [
+        "stringMap"
+      ],
+      "additionalProperties": false
+    },
+    "unitStructNewType": {
+      "type": "object",
+      "properties": {
+        "unitStructNewType": {
+          "$ref": "#/$defs/UnitStruct"
+        }
+      },
+      "required": [
+        "unitStructNewType"
+      ],
+      "additionalProperties": false
+    },
+    "UnitStruct": {
+      "type": "null"
+    },
+    "structNewType": {
+      "type": "object",
+      "properties": {
+        "structNewType": {
+          "$ref": "#/$defs/Struct"
+        }
+      },
+      "required": [
+        "structNewType"
+      ],
+      "additionalProperties": false
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    },
+    "struct": {
+      "type": "object",
+      "properties": {
+        "struct": {
+          "type": "object",
+          "properties": {
+            "foo": {
+              "type": "integer",
+              "format": "int32"
+            },
+            "bar": {
+              "type": "boolean"
+            }
+          },
+          "required": [
+            "foo",
+            "bar"
+          ]
+        }
+      },
+      "required": [
+        "struct"
+      ],
+      "additionalProperties": false
+    },
+    "tuple": {
+      "type": "object",
+      "properties": {
+        "tuple": {
+          "type": "array",
+          "prefixItems": [
+            {
+              "type": "integer",
+              "format": "int32"
+            },
+            {
+              "type": "boolean"
+            }
+          ],
+          "minItems": 2,
+          "maxItems": 2
+        }
+      },
+      "required": [
+        "tuple"
+      ],
+      "additionalProperties": false
+    },
+    "unitTwo": {
+      "type": "string",
+      "const": "unitTwo"
+    },
+    "unitAsInt": {
+      "type": "object",
+      "properties": {
+        "unitAsInt": {
+          "type": "integer",
+          "format": "uint64",
+          "minimum": 0
+        }
+      },
+      "required": [
+        "unitAsInt"
+      ],
+      "additionalProperties": false
+    },
+    "tupleAsStr": {
+      "type": "object",
+      "properties": {
+        "tupleAsStr": {
+          "type": "string",
+          "pattern": "^\\d+ (true|false)$"
+        }
+      },
+      "required": [
+        "tupleAsStr"
+      ],
+      "additionalProperties": false
+    }
+  }
+}
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~internally_tagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~internally_tagged_enum.json
new file mode 100644
index 0000000..40fb250
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~internally_tagged_enum.json
@@ -0,0 +1,127 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Internal",
+  "oneOf": [
+    {
+      "$ref": "#/$defs/UnitOne"
+    },
+    {
+      "$ref": "#/$defs/StringMap"
+    },
+    {
+      "$ref": "#/$defs/UnitStructNewType"
+    },
+    {
+      "$ref": "#/$defs/StructNewType"
+    },
+    {
+      "$ref": "#/$defs/Struct2"
+    },
+    {
+      "$ref": "#/$defs/UnitTwo"
+    }
+  ],
+  "$defs": {
+    "UnitOne": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitOne"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    "StringMap": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "StringMap"
+        }
+      },
+      "additionalProperties": {
+        "type": "string"
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    "UnitStructNewType": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitStructNewType"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    },
+    "StructNewType": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "StructNewType"
+        }
+      },
+      "$ref": "#/$defs/Struct",
+      "required": [
+        "tag"
+      ]
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    },
+    "Struct2": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        },
+        "tag": {
+          "type": "string",
+          "const": "Struct"
+        }
+      },
+      "required": [
+        "tag",
+        "foo",
+        "bar"
+      ]
+    },
+    "UnitTwo": {
+      "type": "object",
+      "properties": {
+        "tag": {
+          "type": "string",
+          "const": "UnitTwo"
+        }
+      },
+      "required": [
+        "tag"
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~no_variants.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~no_variants.json
new file mode 100644
index 0000000..6e80137
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~no_variants.json
@@ -0,0 +1,5 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "NoVariants",
+  "not": {}
+}
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~renamed.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~renamed.json
new file mode 100644
index 0000000..954c911
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~renamed.json
@@ -0,0 +1,54 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Renamed",
+  "oneOf": [
+    {
+      "$ref": "#/$defs/struct_variant"
+    },
+    {
+      "$ref": "#/$defs/custom%20name%20variant"
+    }
+  ],
+  "$defs": {
+    "struct_variant": {
+      "type": "object",
+      "properties": {
+        "struct_variant": {
+          "type": "object",
+          "properties": {
+            "FIELD": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "FIELD"
+          ]
+        }
+      },
+      "required": [
+        "struct_variant"
+      ],
+      "additionalProperties": false
+    },
+    "custom name variant": {
+      "type": "object",
+      "properties": {
+        "custom name variant": {
+          "type": "object",
+          "properties": {
+            "custom name field": {
+              "type": "string"
+            }
+          },
+          "required": [
+            "custom name field"
+          ]
+        }
+      },
+      "required": [
+        "custom name variant"
+      ],
+      "additionalProperties": false
+    }
+  }
+}
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~untagged_enum.json b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~untagged_enum.json
new file mode 100644
index 0000000..e69d763
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/enums_ref_variants.rs~untagged_enum.json
@@ -0,0 +1,111 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Untagged",
+  "anyOf": [
+    {
+      "$ref": "#/$defs/UnitOne"
+    },
+    {
+      "$ref": "#/$defs/StringMap"
+    },
+    {
+      "$ref": "#/$defs/UnitStructNewType"
+    },
+    {
+      "$ref": "#/$defs/StructNewType"
+    },
+    {
+      "$ref": "#/$defs/Struct2"
+    },
+    {
+      "$ref": "#/$defs/Tuple"
+    },
+    {
+      "$ref": "#/$defs/UnitTwo"
+    },
+    {
+      "$ref": "#/$defs/UnitAsInt"
+    },
+    {
+      "$ref": "#/$defs/TupleAsStr"
+    }
+  ],
+  "$defs": {
+    "UnitOne": {
+      "type": "null"
+    },
+    "StringMap": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "UnitStructNewType": {
+      "$ref": "#/$defs/UnitStruct"
+    },
+    "UnitStruct": {
+      "type": "null"
+    },
+    "StructNewType": {
+      "$ref": "#/$defs/Struct"
+    },
+    "Struct": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    },
+    "Struct2": {
+      "type": "object",
+      "properties": {
+        "foo": {
+          "type": "integer",
+          "format": "int32"
+        },
+        "bar": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "foo",
+        "bar"
+      ]
+    },
+    "Tuple": {
+      "type": "array",
+      "prefixItems": [
+        {
+          "type": "integer",
+          "format": "int32"
+        },
+        {
+          "type": "boolean"
+        }
+      ],
+      "minItems": 2,
+      "maxItems": 2
+    },
+    "UnitTwo": {
+      "type": "null"
+    },
+    "UnitAsInt": {
+      "type": "integer",
+      "format": "uint64",
+      "minimum": 0
+    },
+    "TupleAsStr": {
+      "type": "string",
+      "pattern": "^\\d+ (true|false)$"
+    }
+  }
+}
\ No newline at end of file