diff --git a/src/lang/ts/error.rs b/src/lang/ts/error.rs index 54d8326..996977d 100644 --- a/src/lang/ts/error.rs +++ b/src/lang/ts/error.rs @@ -41,6 +41,8 @@ pub enum ExportError { InvalidName(NamedLocation, ExportPath, String), #[error("Attempted to export '{0}' with tagging but the type is not tagged.")] InvalidTagging(ExportPath), + #[error("Attempted to export '{0}' with internal tagging but the variant is a tuple struct.")] + InvalidTaggedVariantContainingTupleStruct(ExportPath), #[error("Unable to export type named '{0}' from locations '{:?}' '{:?}'", .1.as_str(), .2.as_str())] DuplicateTypeName(Cow<'static, str>, ImplLocation, ImplLocation), #[error("IO error: {0}")] @@ -63,6 +65,10 @@ impl PartialEq for ExportError { l0 == r0 && l1 == r1 && l2 == r2 } (Self::InvalidTagging(l0), Self::InvalidTagging(r0)) => l0 == r0, + ( + Self::InvalidTaggedVariantContainingTupleStruct(l0), + Self::InvalidTaggedVariantContainingTupleStruct(r0), + ) => l0 == r0, (Self::DuplicateTypeName(l0, l1, l2), Self::DuplicateTypeName(r0, r1, r2)) => { l0 == r0 && l1 == r1 && l2 == r2 } diff --git a/src/lang/ts/mod.rs b/src/lang/ts/mod.rs index 511caa1..faddc41 100644 --- a/src/lang/ts/mod.rs +++ b/src/lang/ts/mod.rs @@ -528,15 +528,24 @@ fn enum_datatype(ctx: ExportContext, e: &EnumType, type_map: &TypeMap) -> Output format!("{{ {tag}: {sanitised_name} }}") } (EnumRepr::Internal { tag }, EnumVariants::Unnamed(tuple)) => { - let mut typ = unnamed_fields_datatype( - ctx.clone(), - &skip_fields(tuple.fields()).collect::>(), - type_map, - )?; + let fields = skip_fields(tuple.fields()).collect::>(); + + // This field is only required for `{ty}` not `[...]` so we only need to check when there one field + let dont_join_ty = if tuple.fields().len() == 1 { + let (_, ty) = fields.first().expect("checked length above"); + validate_type_for_tagged_intersection( + ctx.clone(), + (**ty).clone(), + type_map, + )? + } else { + false + }; - // TODO: This `null` check is a bad fix for an internally tagged type with a `null` variant being exported as `{ type: "A" } & null` (which is `never` in TS) - // TODO: Move this check into the macros so it can apply to any language cause it should (it's just hard to do in the macros) - if typ == "null" { + let mut typ = + unnamed_fields_datatype(ctx.clone(), &fields, type_map)?; + + if dont_join_ty { format!("({{ {tag}: {sanitised_name} }})") } else { // We wanna be sure `... & ... | ...` becomes `... & (... | ...)` @@ -690,6 +699,76 @@ pub(crate) fn sanitise_type_name(ctx: ExportContext, loc: NamedLocation, ident: Ok(ident.to_string()) } +fn validate_type_for_tagged_intersection( + ctx: ExportContext, + ty: DataType, + type_map: &TypeMap, +) -> Result { + match ty { + DataType::Any + | DataType::Unknown + | DataType::Primitive(_) + // `T & null` is `never` but `T & (U | null)` (this variant) is `T & U` so it's fine. + | DataType::Nullable(_) + | DataType::List(_) + | DataType::Map(_) + | DataType::Result(_) + | DataType::Generic(_) => Ok(false), + DataType::Literal(v) => match v { + LiteralType::None => Ok(true), + _ => Ok(false), + }, + DataType::Struct(v) => match v.fields { + StructFields::Unit => Ok(true), + StructFields::Unnamed(_) => { + Err(ExportError::InvalidTaggedVariantContainingTupleStruct( + ctx.export_path() + )) + } + StructFields::Named(fields) => { + // Prevent `{ tag: "{tag}" } & Record` + if fields.tag.is_none() && fields.fields.len() == 0 { + return Ok(true); + } + + return Ok(false); + } + }, + DataType::Enum(v) => { + match v.repr { + EnumRepr::Untagged => { + Ok(v.variants.iter().any(|(_, v)| match &v.inner { + // `{ .. } & null` is `never` + EnumVariants::Unit => true, + // `{ ... } & Record` is not useful + EnumVariants::Named(v) => v.tag.is_none() && v.fields().len() == 0, + EnumVariants::Unnamed(_) => false, + })) + }, + // All of these repr's are always objects. + EnumRepr::Internal { .. } | EnumRepr::Adjacent { .. } | EnumRepr::External => Ok(false), + } + } + DataType::Tuple(v) => { + // Empty tuple is `null` + if v.elements.len() == 0 { + return Ok(true); + } + + Ok(false) + } + DataType::Reference(r) => validate_type_for_tagged_intersection( + ctx, + type_map + .get(r.sid) + .expect("TypeMap should have been populated by now") + .inner + .clone(), + type_map, + ), + } +} + const ANY: &str = "any"; const UNKNOWN: &str = "unknown"; const NUMBER: &str = "number"; diff --git a/tests/remote_impls.rs b/tests/remote_impls.rs index 0ca6659..2ba3fe0 100644 --- a/tests/remote_impls.rs +++ b/tests/remote_impls.rs @@ -53,6 +53,6 @@ fn typescript_types_bevy_ecs() { ts::export::( &ExportConfig::default().bigint(BigIntExportBehavior::Number) ), - Ok("export Entity = number;".into()) + Ok("export type Entity = number".into()) ); } diff --git a/tests/serde/empty_enum.rs b/tests/serde/empty_enum.rs index d52cc2c..b52f5f5 100644 --- a/tests/serde/empty_enum.rs +++ b/tests/serde/empty_enum.rs @@ -1,4 +1,7 @@ -use specta::Type; +use specta::{ + ts::{ExportError, ExportPath}, + Type, +}; use crate::ts::assert_ts; @@ -18,11 +21,70 @@ enum C {} #[specta(export = false, untagged)] enum D {} +#[derive(Type)] +#[specta(export = false)] +pub struct Inner; + +#[derive(Type)] +#[specta(export = false)] +pub struct Inner2 {} + +#[derive(Type)] +#[specta(export = false)] +pub struct Inner3(); + +#[derive(Type)] +#[specta(export = false, tag = "a")] +enum E { + A(Inner), + B(Inner), +} + +#[derive(Type)] +#[specta(export = false, tag = "a")] +enum F { + A(Inner2), + B(Inner2), +} + +#[derive(Type)] +#[specta(export = false, tag = "a")] +enum G { + A(Inner3), + B(Inner3), +} + +#[derive(Type)] +#[specta(export = false, tag = "a")] +enum H { + #[specta(skip)] + A(Inner3), + B(Inner2), +} + +#[derive(Type)] +#[specta(transparent)] +pub struct Demo(()); + +#[derive(Type)] +#[specta(export = false, tag = "a")] +enum I { + A(Demo), + B(Demo), +} + +// https://github.com/oscartbeaumont/specta/issues/174 #[test] fn empty_enums() { - // `never & { tag = "a" }` would collease to `never` so we don't need to include it. + // `never & { tag = "a" }` would coalesce to `never` so we don't need to include it. assert_ts!(A, "never"); assert_ts!(B, "never"); assert_ts!(C, "never"); assert_ts!(D, "never"); + + assert_ts!(E, "({ a: \"A\" }) | ({ a: \"B\" })"); + assert_ts!(F, "({ a: \"A\" }) | ({ a: \"B\" })"); + assert_ts!(error; G, ExportError::InvalidTaggedVariantContainingTupleStruct(ExportPath::new_unsafe("G"))); + assert_ts!(H, "({ a: \"B\" })"); + assert_ts!(I, "({ a: \"A\" }) | ({ a: \"B\" })"); } diff --git a/tests/serde/empty_struct.rs b/tests/serde/empty_struct.rs index 10b0d7f..34eff67 100644 --- a/tests/serde/empty_struct.rs +++ b/tests/serde/empty_struct.rs @@ -10,6 +10,7 @@ struct A {} #[specta(export = false, tag = "a")] struct B {} +// https://github.com/oscartbeaumont/specta/issues/174 #[test] fn empty_enums() { assert_ts!(A, "Record"); diff --git a/tests/serde/internally_tagged.rs b/tests/serde/internally_tagged.rs index 5b0c624..29cdabe 100644 --- a/tests/serde/internally_tagged.rs +++ b/tests/serde/internally_tagged.rs @@ -128,7 +128,7 @@ fn internally_tagged() { assert_ts!(E, "({ type: \"A\" })"); assert_ts!(F, "({ type: \"A\" } & FInner)"); assert_ts!(error; G, SerdeError::InvalidInternallyTaggedEnum); - assert_ts!(H, "({ type: \"A\" } & HInner)"); + assert_ts!(H, "({ type: \"A\" })"); assert_ts!(error; I, SerdeError::InvalidInternallyTaggedEnum); assert_ts!(L, "({ type: \"A\" } & ({ type: \"A\" } | { type: \"B\" }))"); assert_ts!(M, "({ type: \"A\" })");