Skip to main content

specta_typescript/
primitives.rs

1//! Primitives provide building blocks for Specta-based libraries.
2//!
3//! These are for advanced usecases, you should generally use [crate::Typescript] or
4//! [crate::JSDoc] in end-user applications.
5
6use std::{
7    borrow::Cow,
8    collections::{BTreeSet, HashMap, HashSet, hash_map::Entry},
9};
10
11use specta::{
12    Format, Types,
13    datatype::{
14        DataType, Deprecated, Enum, Field, Fields, GenericDefinition, GenericReference, List, Map,
15        NamedDataType, NamedReference, NamedReferenceType, OpaqueReference, Primitive, Reference,
16        Struct, Tuple, Variant,
17    },
18};
19
20use crate::{
21    Branded, BrandedTypeExporter, Error, Exporter, Layout, map_keys, opaque,
22    reserved_names::RESERVED_TYPE_NAMES,
23};
24
25const STRING: &str = "string";
26const NULL: &str = "null";
27const NEVER: &str = "never";
28const FIELD_ALIAS_UNION_MARKER: &str = "specta_serde:deferred_alias_union";
29const FIELD_ALIAS_EXCLUSION_MARKER: &str = "specta_serde:alias_exclusion";
30
31fn path_string(location: &[Cow<'static, str>]) -> String {
32    location.join(".")
33}
34
35fn rust_type_path(ndt: &NamedDataType) -> Cow<'static, str> {
36    if ndt.module_path.is_empty() {
37        ndt.name.clone()
38    } else {
39        Cow::Owned(format!("{}::{}", ndt.module_path, ndt.name))
40    }
41}
42
43fn module_prefixed_type_name(ndt: &NamedDataType) -> String {
44    let mut name = ndt.module_path.split("::").collect::<Vec<_>>().join("_");
45    if !name.is_empty() {
46        name.push('_');
47    }
48    name.push_str(&ndt.name);
49    name
50}
51
52fn exported_type_name<'a>(exporter: &Exporter, ndt: &'a NamedDataType) -> Cow<'a, str> {
53    match exporter.layout {
54        Layout::ModulePrefixedName => Cow::Owned(module_prefixed_type_name(ndt)),
55        _ => ndt.name.clone(),
56    }
57}
58
59fn referenced_type_name<'a>(exporter: &Exporter, ndt: &'a NamedDataType) -> Cow<'a, str> {
60    match exporter.layout {
61        Layout::ModulePrefixedName => Cow::Owned(module_prefixed_type_name(ndt)),
62        Layout::Namespaces => {
63            if ndt.module_path.is_empty() {
64                ndt.name.clone()
65            } else {
66                let mut path =
67                    ndt.module_path
68                        .split("::")
69                        .fold("$s$.".to_string(), |mut s, segment| {
70                            s.push_str(segment);
71                            s.push('.');
72                            s
73                        });
74                path.push_str(&ndt.name);
75                Cow::Owned(path)
76            }
77        }
78        Layout::Files => {
79            let current_module_path = crate::references::current_module_path().unwrap_or_default();
80
81            if ndt.module_path == current_module_path {
82                ndt.name.clone()
83            } else {
84                let mut path = crate::exporter::module_alias(&ndt.module_path);
85                path.push('.');
86                path.push_str(&ndt.name);
87                Cow::Owned(path)
88            }
89        }
90        _ => ndt.name.clone(),
91    }
92}
93
94fn inner_comments(
95    deprecated: Option<&Deprecated>,
96    docs: &str,
97    other: String,
98    start_with_newline: bool,
99    prefix: &str,
100) -> String {
101    let mut comments = String::new();
102    js_doc(&mut comments, docs, deprecated);
103    if comments.is_empty() {
104        return other;
105    }
106
107    let mut out = String::new();
108    if start_with_newline {
109        out.push('\n');
110    }
111
112    for line in comments.lines() {
113        out.push_str(prefix);
114        out.push_str(line);
115        out.push('\n');
116    }
117
118    out.push_str(&other);
119    out
120}
121
122pub(crate) fn is_identifier(name: &str) -> bool {
123    let mut chars = name.chars();
124    let Some(first) = chars.next() else {
125        return false;
126    };
127
128    (first.is_ascii_alphabetic() || first == '_' || first == '$')
129        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
130}
131
132pub(crate) fn escape_typescript_string_literal(value: &str) -> Cow<'_, str> {
133    if !value.chars().any(|ch| {
134        ch == '"' || ch == '\\' || ch == '\u{2028}' || ch == '\u{2029}' || ch.is_control()
135    }) {
136        return Cow::Borrowed(value);
137    }
138
139    let mut escaped = String::with_capacity(value.len());
140    for ch in value.chars() {
141        match ch {
142            '"' => escaped.push_str(r#"\""#),
143            '\\' => escaped.push_str(r#"\\"#),
144            '\n' => escaped.push_str(r#"\n"#),
145            '\r' => escaped.push_str(r#"\r"#),
146            '\t' => escaped.push_str(r#"\t"#),
147            '\u{2028}' => escaped.push_str(r#"\u2028"#),
148            '\u{2029}' => escaped.push_str(r#"\u2029"#),
149            ch if ch.is_control() => push_unicode_escape(&mut escaped, ch),
150            _ => escaped.push(ch),
151        }
152    }
153
154    Cow::Owned(escaped)
155}
156
157fn push_unicode_escape(s: &mut String, ch: char) {
158    const HEX: &[u8; 16] = b"0123456789ABCDEF";
159    let value = ch as u32;
160
161    s.push_str(r#"\u"#);
162    s.push(HEX[((value >> 12) & 0xF) as usize] as char);
163    s.push(HEX[((value >> 8) & 0xF) as usize] as char);
164    s.push(HEX[((value >> 4) & 0xF) as usize] as char);
165    s.push(HEX[(value & 0xF) as usize] as char);
166}
167
168fn sanitise_key<'a>(field_name: Cow<'static, str>, force_string: bool) -> Cow<'a, str> {
169    if force_string || !is_identifier(&field_name) {
170        format!(r#""{}""#, escape_typescript_string_literal(&field_name)).into()
171    } else {
172        field_name
173    }
174}
175
176fn sanitise_type_name(location: &[Cow<'static, str>], ident: &str) -> Result<String, Error> {
177    let path = path_string(location);
178
179    if ident.is_empty() {
180        return Err(Error::empty_name(path));
181    }
182
183    if let Some(name) = RESERVED_TYPE_NAMES.iter().find(|v| **v == ident) {
184        return Err(Error::forbidden_name(path, name));
185    }
186
187    if let Some(first_char) = ident.chars().next()
188        && !first_char.is_alphabetic()
189        && first_char != '_'
190    {
191        return Err(Error::invalid_name(path, ident.to_string()));
192    }
193
194    if ident
195        .find(|c: char| !c.is_alphanumeric() && c != '_')
196        .is_some()
197    {
198        return Err(Error::invalid_name(path, ident.to_string()));
199    }
200
201    Ok(ident.to_string())
202}
203
204pub(crate) fn js_doc(s: &mut String, docs: &str, deprecated: Option<&Deprecated>) {
205    if docs.is_empty() && deprecated.is_none() {
206        return;
207    }
208
209    if deprecated.is_none() {
210        let mut lines = docs.lines();
211        if let (Some(line), None) = (lines.next(), lines.next()) {
212            s.push_str("/** ");
213            s.push_str(&escape_jsdoc_text(line));
214            s.push_str(" */\n");
215            return;
216        }
217    }
218
219    s.push_str("/**\n");
220    if !docs.is_empty() {
221        for line in docs.lines() {
222            s.push_str(" * ");
223            s.push_str(&escape_jsdoc_text(line));
224            s.push('\n');
225        }
226    }
227
228    if let Some(typ) = deprecated {
229        s.push_str(" * @deprecated");
230        if let Some(details) = deprecated_details(typ) {
231            s.push(' ');
232            s.push_str(&details);
233        }
234        s.push('\n');
235    }
236
237    s.push_str(" */\n");
238}
239
240pub(crate) fn escape_jsdoc_text(text: &str) -> Cow<'_, str> {
241    if text.contains("*/") {
242        Cow::Owned(text.replace("*/", "*\\/"))
243    } else {
244        Cow::Borrowed(text)
245    }
246}
247
248pub(crate) fn deprecated_details(typ: &Deprecated) -> Option<String> {
249    typ.note
250        .as_deref()
251        .map(str::trim)
252        .filter(|note| !note.is_empty())
253        .map(str::to_string)
254}
255
256/// Generate a group of `export Type = ...` Typescript string for a specific [`NamedDataType`].
257///
258/// This method leaves the following up to the implementer:
259///  - Ensuring all referenced types are exported
260///  - Handling multiple type with overlapping names
261///  - Transforming the type for your serialization format (Eg. Serde)
262///
263/// We recommend passing in your types in bulk instead of doing individual calls as it leaves formatting to us and also allows us to merge the JSDoc types into a single large comment.
264///
265/// If you are using a custom format such as `serde::format` with the high-level exporter,
266/// these primitive helpers do not apply that mapping automatically. Standalone primitive usage
267/// should map both the full [`Types`] graph and any top-level [`DataType`] values with matching
268/// helpers first.
269///
270pub fn export<'a>(
271    exporter: &dyn AsRef<Exporter>,
272    types: &Types,
273    ndts: impl Iterator<Item = &'a NamedDataType>,
274    indent: &str,
275) -> Result<String, Error> {
276    let mut s = String::new();
277    export_internal(&mut s, exporter.as_ref(), None, types, ndts, indent)?;
278    Ok(s)
279}
280
281pub(crate) fn export_internal<'a>(
282    s: &mut String,
283    exporter: &Exporter,
284    format: Option<&dyn Format>,
285    types: &Types,
286    ndts: impl Iterator<Item = &'a NamedDataType>,
287    indent: &str,
288) -> Result<(), Error> {
289    let ndts = ndts.filter(|ndt| ndt.ty.is_some());
290
291    if exporter.jsdoc {
292        let mut ndts = ndts.peekable();
293        if ndts.peek().is_none() {
294            return Ok(());
295        }
296
297        s.push_str(indent);
298        s.push_str("/**\n");
299
300        for (index, ndt) in ndts.enumerate() {
301            if index != 0 {
302                s.push_str(indent);
303                s.push_str("\t*\n");
304            }
305
306            append_typedef_body(s, exporter, format, types, ndt, indent)?;
307        }
308
309        s.push_str(indent);
310        s.push_str("\t*/\n");
311        return Ok(());
312    }
313
314    for (index, ndt) in ndts.enumerate() {
315        if index != 0 {
316            s.push('\n');
317        }
318
319        export_single_internal(s, exporter, format, types, ndt, indent)?;
320    }
321
322    Ok(())
323}
324
325fn export_single_internal(
326    s: &mut String,
327    exporter: &Exporter,
328    format: Option<&dyn Format>,
329    types: &Types,
330    ndt: &NamedDataType,
331    indent: &str,
332) -> Result<(), Error> {
333    if exporter.jsdoc {
334        let mut typedef = String::new();
335        typedef_internal(&mut typedef, exporter, format, types, ndt)?;
336        for line in typedef.lines() {
337            s.push_str(indent);
338            s.push_str(line);
339            s.push('\n');
340        }
341        return Ok(());
342    }
343
344    let raw_name = exported_type_name(exporter, ndt);
345    let name = sanitise_type_name(&[rust_type_path(ndt)], &raw_name)
346        .map_err(|err| err.with_named_datatype(ndt))?;
347
348    let mut comments = String::new();
349    js_doc(&mut comments, &ndt.docs, ndt.deprecated.as_ref());
350    if !comments.is_empty() {
351        for line in comments.lines() {
352            s.push_str(indent);
353            s.push_str(line);
354            s.push('\n');
355        }
356    }
357
358    s.push_str(indent);
359    s.push_str("export type ");
360    s.push_str(&name);
361    write_generic_parameters(s, exporter, types, &[rust_type_path(ndt)], &ndt.generics)?;
362    s.push_str(" = ");
363
364    let body = resolve_named_export_body(types, ndt)?;
365    datatype(
366        s,
367        exporter,
368        format,
369        types,
370        &body,
371        vec![rust_type_path(ndt)],
372        Some(ndt.name.as_ref()),
373        indent,
374        Default::default(),
375    )
376    .map_err(|err| err.with_named_datatype(ndt))?;
377    s.push_str(";\n");
378
379    Ok(())
380}
381
382/// Generate an anonymous Typescript string for a specific [`DataType`].
383///
384/// This method leaves all the same things as the [`export`] method up to the user.
385///
386/// Note that calling this method with a tagged struct or enum may cause the tag to not be exported.
387/// The type should be wrapped in a [`NamedDataType`] to provide a proper name.
388///
389/// You are responsible for apply Serde or other format mapping to the top-level datatype in the
390/// same way as the [`Types`] graph before calling this helper.
391///
392pub fn inline(
393    exporter: &dyn AsRef<Exporter>,
394    types: &Types,
395    dt: &DataType,
396) -> Result<String, Error> {
397    let mut s = String::new();
398    inline_datatype(
399        &mut s,
400        exporter.as_ref(),
401        None,
402        types,
403        dt,
404        vec![],
405        None,
406        "",
407        0,
408        &[],
409    )?;
410    Ok(s)
411}
412
413// This can be used internally to prevent cloning `Typescript` instances.
414// Externally this shouldn't be a concern so we don't expose it.
415pub(crate) fn typedef_internal(
416    s: &mut String,
417    exporter: &Exporter,
418    format: Option<&dyn Format>,
419    types: &Types,
420    dt: &NamedDataType,
421) -> Result<(), Error> {
422    s.push_str("/**\n");
423    append_typedef_body(s, exporter, format, types, dt, "")?;
424
425    s.push_str("\t*/");
426
427    Ok(())
428}
429
430fn append_jsdoc_properties(
431    s: &mut String,
432    exporter: &Exporter,
433    format: Option<&dyn Format>,
434    types: &Types,
435    dt_name: &str,
436    dt: &DataType,
437    indent: &str,
438) -> Result<(), Error> {
439    match dt {
440        DataType::Struct(strct) => match &strct.fields {
441            Fields::Unit => {}
442            Fields::Unnamed(unnamed) => {
443                for (idx, field) in unnamed.fields.iter().enumerate() {
444                    let Some(ty) = field.ty.as_ref() else {
445                        continue;
446                    };
447
448                    let mut ty_str = String::new();
449                    let datatype_prefix = format!("{indent}\t*\t");
450                    datatype(
451                        &mut ty_str,
452                        exporter,
453                        format,
454                        types,
455                        ty,
456                        vec![Cow::Owned(dt_name.to_owned()), idx.to_string().into()],
457                        Some(dt_name),
458                        &datatype_prefix,
459                        Default::default(),
460                    )?;
461
462                    push_jsdoc_property(
463                        s,
464                        &ty_str,
465                        &idx.to_string(),
466                        field.optional,
467                        &field.docs,
468                        field.deprecated.as_ref(),
469                        indent,
470                    );
471                }
472            }
473            Fields::Named(named) => {
474                for (name, field) in &named.fields {
475                    let Some(ty) = field.ty.as_ref() else {
476                        continue;
477                    };
478
479                    let mut ty_str = String::new();
480                    let datatype_prefix = format!("{indent}\t*\t");
481                    datatype(
482                        &mut ty_str,
483                        exporter,
484                        format,
485                        types,
486                        ty,
487                        vec![Cow::Owned(dt_name.to_owned()), name.clone()],
488                        Some(dt_name),
489                        &datatype_prefix,
490                        Default::default(),
491                    )?;
492
493                    push_jsdoc_property(
494                        s,
495                        &ty_str,
496                        name,
497                        field.optional,
498                        &field.docs,
499                        field.deprecated.as_ref(),
500                        indent,
501                    );
502                }
503            }
504        },
505        DataType::Enum(enm) => {
506            for (variant_name, variant) in enm.variants.iter().filter(|(_, v)| !v.skip) {
507                let mut one_variant_enum = enm.clone();
508                one_variant_enum
509                    .variants
510                    .retain(|(name, _)| name == variant_name);
511
512                let mut variant_ty = String::new();
513                enum_dt(
514                    &mut variant_ty,
515                    exporter,
516                    types,
517                    &one_variant_enum,
518                    vec![Cow::Owned(dt_name.to_owned())],
519                    "",
520                    &[],
521                )?;
522
523                push_jsdoc_property(
524                    s,
525                    &variant_ty,
526                    variant_name,
527                    false,
528                    &variant.docs,
529                    variant.deprecated.as_ref(),
530                    indent,
531                );
532            }
533        }
534        DataType::Intersection(types_) => {
535            for ty in types_ {
536                append_jsdoc_properties(s, exporter, format, types, dt_name, ty, indent)?;
537            }
538        }
539        _ => {}
540    }
541
542    Ok(())
543}
544
545fn push_jsdoc_property(
546    s: &mut String,
547    ty: &str,
548    name: &str,
549    optional: bool,
550    docs: &str,
551    deprecated: Option<&Deprecated>,
552    indent: &str,
553) {
554    s.push_str(indent);
555    s.push_str("\t* @property {");
556    push_jsdoc_type(s, ty, indent);
557    s.push_str("} ");
558    s.push_str(&jsdoc_property_name(name, optional));
559
560    if let Some(description) = jsdoc_description(docs, deprecated) {
561        s.push_str(" - ");
562        s.push_str(&description);
563    }
564
565    s.push('\n');
566}
567
568fn push_jsdoc_type(s: &mut String, ty: &str, indent: &str) {
569    let mut lines = ty.lines();
570    if let Some(first_line) = lines.next() {
571        s.push_str(first_line);
572    }
573
574    for line in lines {
575        s.push('\n');
576
577        if line
578            .strip_prefix(indent)
579            .is_some_and(|rest| rest.starts_with("\t*"))
580        {
581            s.push_str(line);
582        } else {
583            s.push_str(indent);
584            s.push_str("\t* ");
585            s.push_str(line);
586        }
587    }
588}
589
590fn jsdoc_property_name(name: &str, optional: bool) -> String {
591    let name = if is_identifier(name) {
592        name.to_string()
593    } else {
594        format!("\"{}\"", escape_typescript_string_literal(name))
595    };
596
597    if optional { format!("[{name}]") } else { name }
598}
599
600fn append_typedef_body(
601    s: &mut String,
602    exporter: &Exporter,
603    format: Option<&dyn Format>,
604    types: &Types,
605    dt: &NamedDataType,
606    indent: &str,
607) -> Result<(), Error> {
608    let name = &dt.name;
609    let mut type_name = String::from(name.as_ref());
610    write_generic_parameters(
611        &mut type_name,
612        exporter,
613        types,
614        &[rust_type_path(dt)],
615        &dt.generics,
616    )?;
617
618    let mut typedef_ty = String::new();
619    let datatype_prefix = format!("{indent}\t*\t");
620    let body = resolve_named_export_body(types, dt)?;
621    datatype(
622        &mut typedef_ty,
623        exporter,
624        format,
625        types,
626        &body,
627        vec![rust_type_path(dt)],
628        Some(dt.name.as_ref()),
629        &datatype_prefix,
630        Default::default(),
631    )
632    .map_err(|err| err.with_named_datatype(dt))?;
633
634    if !dt.docs.is_empty() {
635        for line in dt.docs.lines() {
636            s.push_str(indent);
637            s.push_str("\t* ");
638            s.push_str(&escape_jsdoc_text(line));
639            s.push('\n');
640        }
641        s.push_str(indent);
642        s.push_str("\t*\n");
643    }
644
645    if let Some(deprecated) = dt.deprecated.as_ref() {
646        s.push_str(indent);
647        s.push_str("\t* @deprecated");
648        if let Some(details) = deprecated_details(deprecated) {
649            s.push(' ');
650            s.push_str(&details);
651        }
652        s.push('\n');
653    }
654
655    s.push_str(indent);
656    s.push_str("\t* @typedef {");
657    push_jsdoc_type(s, &typedef_ty, indent);
658    s.push_str("} ");
659    s.push_str(&type_name);
660    s.push('\n');
661
662    if let Some(ty) = &dt.ty {
663        let dt_path = rust_type_path(dt);
664        append_jsdoc_properties(s, exporter, format, types, dt_path.as_ref(), ty, indent)?;
665    }
666
667    Ok(())
668}
669
670fn write_generic_parameters(
671    s: &mut String,
672    exporter: &Exporter,
673    types: &Types,
674    parent_location: &[Cow<'static, str>],
675    generics: &[GenericDefinition],
676) -> Result<(), Error> {
677    if generics.is_empty() {
678        return Ok(());
679    }
680
681    s.push('<');
682    for (index, generic) in generics.iter().enumerate() {
683        if index != 0 {
684            s.push_str(", ");
685        }
686
687        s.push_str(generic.name.as_ref());
688
689        if let Some(default) = &generic.default {
690            let mut rendered_default = String::new();
691            let mut default_location = parent_location.to_vec();
692            default_location.push(format!("<generic {} default>", generic.name).into());
693            shallow_inline_datatype(
694                &mut rendered_default,
695                exporter,
696                None,
697                types,
698                default,
699                default_location,
700                None,
701                "",
702                Default::default(),
703            )?;
704            s.push_str(" = ");
705            s.push_str(&rendered_default);
706        }
707    }
708    s.push('>');
709
710    Ok(())
711}
712
713fn jsdoc_description(docs: &str, deprecated: Option<&Deprecated>) -> Option<String> {
714    let docs = docs
715        .lines()
716        .map(str::trim)
717        .filter(|line| !line.is_empty())
718        .map(|line| escape_jsdoc_text(line).into_owned())
719        .collect::<Vec<_>>()
720        .join(" ");
721
722    let deprecated = deprecated.map(|deprecated| {
723        let mut value = String::from("@deprecated");
724        if let Some(details) = deprecated_details(deprecated) {
725            value.push(' ');
726            value.push_str(&escape_jsdoc_text(&details));
727        }
728        value
729    });
730
731    match (docs.is_empty(), deprecated) {
732        (true, None) => None,
733        (true, Some(deprecated)) => Some(deprecated),
734        (false, None) => Some(docs),
735        (false, Some(deprecated)) => Some(format!("{docs} {deprecated}")),
736    }
737}
738
739/// Generate a Typescript string for a specific [`DataType`] while preserving ordinary named
740/// references recursively.
741///
742/// Unlike [`inline`], named types are rendered as references even when nested within an anonymous
743/// composite type. References explicitly marked as inline are still expanded.
744///
745/// See [`export`] for the list of things to consider when using this.
746pub fn reference(
747    exporter: &dyn AsRef<Exporter>,
748    types: &Types,
749    dt: &DataType,
750) -> Result<String, Error> {
751    let mut s = String::new();
752    datatype(
753        &mut s,
754        exporter.as_ref(),
755        None,
756        types,
757        dt,
758        vec![],
759        None,
760        "",
761        &[],
762    )?;
763    Ok(s)
764}
765
766pub(crate) fn datatype_with_inline_attr(
767    s: &mut String,
768    exporter: &Exporter,
769    format: Option<&dyn Format>,
770    types: &Types,
771    dt: &DataType,
772    location: Vec<Cow<'static, str>>,
773    parent_name: Option<&str>,
774    prefix: &str,
775    generics: &[(GenericReference, DataType)],
776    shallow_inline: bool,
777) -> Result<(), Error> {
778    if shallow_inline {
779        let inline_path = path_string(&location);
780        return shallow_inline_datatype(
781            s,
782            exporter,
783            format,
784            types,
785            dt,
786            location,
787            parent_name,
788            prefix,
789            generics,
790        )
791        .map_err(|err| err.with_inline_trace(inline_named_datatype(types, dt), inline_path));
792    }
793
794    datatype(
795        s,
796        exporter,
797        format,
798        types,
799        dt,
800        location,
801        parent_name,
802        prefix,
803        generics,
804    )
805}
806
807fn write_generic_reference(s: &mut String, generic: &GenericReference) {
808    s.push_str(generic.name());
809}
810
811fn scoped_reference_generics(
812    parent_generics: &[(GenericReference, DataType)],
813    reference_generics: &[(GenericReference, DataType)],
814) -> Vec<(GenericReference, DataType)> {
815    parent_generics
816        .iter()
817        .filter(|(parent_generic, _)| {
818            !reference_generics
819                .iter()
820                .any(|(child_generic, _)| child_generic == parent_generic)
821        })
822        .cloned()
823        .collect()
824}
825
826fn named_reference_generics(r: &NamedReference) -> Result<&[(GenericReference, DataType)], Error> {
827    match &r.inner {
828        NamedReferenceType::Reference { generics, .. } => Ok(generics),
829        NamedReferenceType::Inline { .. } => Ok(&[]),
830        NamedReferenceType::Recursive(_) => Ok(&[]),
831    }
832}
833
834fn named_reference_ty<'a>(
835    types: &'a Types,
836    r: &'a NamedReference,
837    location: &[Cow<'static, str>],
838) -> Result<&'a DataType, Error> {
839    let path = path_string(location);
840    match &r.inner {
841        NamedReferenceType::Reference { .. } => types
842            .get(r)
843            .and_then(|ndt| ndt.ty.as_ref())
844            .ok_or_else(|| Error::dangling_named_reference(path, format!("{r:?}"))),
845        NamedReferenceType::Inline { dt, .. } => Ok(dt),
846        NamedReferenceType::Recursive(cycle) => Err(Error::infinite_recursive_inline_type(
847            path,
848            format!("{r:?}"),
849            cycle.clone(),
850        )),
851    }
852}
853
854fn inline_named_datatype<'a>(types: &'a Types, dt: &DataType) -> Option<&'a NamedDataType> {
855    match dt {
856        DataType::Reference(Reference::Named(r)) => types.get(r),
857        _ => None,
858    }
859}
860
861fn resolve_scoped_generic_default(
862    default: &DataType,
863    scoped_generics: &[(GenericReference, DataType)],
864) -> DataType {
865    match default {
866        DataType::Generic(default) => scoped_generics
867            .iter()
868            .find_map(|(reference, dt)| (reference == default).then_some(dt.clone()))
869            .unwrap_or_else(|| DataType::Generic(default.clone())),
870        default => default.clone(),
871    }
872}
873
874fn resolved_reference_generics(
875    ndt: &specta::datatype::NamedDataType,
876    r: &NamedReference,
877    parent_generics: &[(GenericReference, DataType)],
878) -> Option<(Vec<DataType>, bool, Vec<(GenericReference, DataType)>)> {
879    let reference_generics = named_reference_generics(r).ok()?;
880    let mut scoped_generics = scoped_reference_generics(parent_generics, reference_generics);
881    let mut all_default = true;
882    let mut rendered_generics = Vec::with_capacity(ndt.generics.len());
883
884    for generic in ndt.generics.iter() {
885        let explicit = reference_generics
886            .iter()
887            .find(|(reference, _)| *reference == generic.reference())
888            .map(|(_, dt)| dt.clone());
889
890        let resolved_default = generic
891            .default
892            .as_ref()
893            .map(|default| resolve_scoped_generic_default(default, &scoped_generics));
894
895        let resolved = explicit.or_else(|| resolved_default.clone()).or_else(|| {
896            Some(DataType::Reference(Reference::opaque(
897                crate::opaque::Unknown,
898            )))
899        });
900
901        let resolved = resolved?;
902        all_default &= resolved_default
903            .as_ref()
904            .is_some_and(|default| default == &resolved);
905        scoped_generics.push((generic.reference(), resolved.clone()));
906        rendered_generics.push(resolved);
907    }
908
909    Some((rendered_generics, all_default, scoped_generics))
910}
911
912#[derive(Clone, Copy)]
913struct RenderCtx<'a> {
914    exporter: &'a Exporter,
915    format: Option<&'a dyn Format>,
916    types: &'a Types,
917    parent_name: Option<&'a str>,
918    prefix: &'a str,
919    generics: &'a [(GenericReference, DataType)],
920}
921
922#[derive(Clone, Copy)]
923enum RenderMode {
924    Normal,
925    ShallowInline,
926}
927
928impl RenderMode {
929    fn render(
930        self,
931        s: &mut String,
932        ctx: RenderCtx<'_>,
933        dt: &DataType,
934        location: Vec<Cow<'static, str>>,
935    ) -> Result<(), Error> {
936        match self {
937            Self::Normal => datatype(
938                s,
939                ctx.exporter,
940                ctx.format,
941                ctx.types,
942                dt,
943                location,
944                ctx.parent_name,
945                ctx.prefix,
946                ctx.generics,
947            ),
948            Self::ShallowInline => shallow_inline_datatype(
949                s,
950                ctx.exporter,
951                ctx.format,
952                ctx.types,
953                dt,
954                location,
955                ctx.parent_name,
956                ctx.prefix,
957                ctx.generics,
958            ),
959        }
960    }
961
962    fn render_intersection_part(
963        self,
964        s: &mut String,
965        ctx: RenderCtx<'_>,
966        dt: &DataType,
967        location: Vec<Cow<'static, str>>,
968    ) -> Result<(), Error> {
969        match (self, dt) {
970            (Self::ShallowInline, DataType::Reference(r)) => reference_dt(
971                s,
972                ctx.exporter,
973                ctx.format,
974                ctx.types,
975                r,
976                location,
977                ctx.prefix,
978                ctx.generics,
979            ),
980            _ => self.render(s, ctx, dt, location),
981        }
982    }
983}
984
985fn render_datatype(
986    s: &mut String,
987    ctx: RenderCtx<'_>,
988    dt: &DataType,
989    location: Vec<Cow<'static, str>>,
990    mode: RenderMode,
991) -> Result<(), Error> {
992    match (mode, dt) {
993        (_, DataType::Primitive(p)) => s.push_str(primitive_dt(p, location)?),
994        (_, DataType::Generic(g)) => write_generic_reference(s, g),
995        (RenderMode::Normal, DataType::List(list)) => {
996            list_dt(s, ctx.exporter, ctx.types, list, location, ctx.generics)?;
997        }
998        (RenderMode::ShallowInline, DataType::List(list)) => {
999            let mut inner = String::new();
1000            render_datatype(&mut inner, ctx, &list.ty, location, mode)?;
1001            push_list(s, &inner, list.length);
1002        }
1003        (RenderMode::Normal, DataType::Map(map)) => {
1004            map_dt(
1005                s,
1006                ctx.exporter,
1007                ctx.format,
1008                ctx.types,
1009                map,
1010                location,
1011                ctx.generics,
1012            )?;
1013        }
1014        (RenderMode::ShallowInline, DataType::Map(map)) => render_map(
1015            s,
1016            ctx.exporter,
1017            ctx.format,
1018            ctx.types,
1019            map,
1020            location,
1021            ctx.parent_name,
1022            ctx.prefix,
1023            ctx.generics,
1024            mode,
1025        )?,
1026        (_, DataType::Nullable(inner)) => {
1027            let mut rendered = String::new();
1028            let child_ctx = RenderCtx { prefix: "", ..ctx };
1029            render_datatype(&mut rendered, child_ctx, inner, location, mode)?;
1030            push_nullable(s, &rendered);
1031        }
1032        (_, DataType::Struct(st)) => struct_dt(
1033            s,
1034            ctx.exporter,
1035            ctx.format,
1036            ctx.types,
1037            st,
1038            location,
1039            ctx.parent_name,
1040            ctx.prefix,
1041            ctx.generics,
1042        )?,
1043        (_, DataType::Enum(enm)) => enum_dt(
1044            s,
1045            ctx.exporter,
1046            ctx.types,
1047            enm,
1048            location,
1049            ctx.prefix,
1050            ctx.generics,
1051        )?,
1052        (RenderMode::Normal, DataType::Tuple(tuple)) => {
1053            tuple_dt(s, ctx.exporter, ctx.types, tuple, location, ctx.generics)?;
1054        }
1055        (RenderMode::ShallowInline, DataType::Tuple(tuple)) => match tuple.elements.as_slice() {
1056            [] => s.push_str(NULL),
1057            elements => {
1058                s.push('[');
1059                for (idx, dt) in elements.iter().enumerate() {
1060                    if idx != 0 {
1061                        s.push_str(", ");
1062                    }
1063                    render_datatype(s, ctx, dt, location.clone(), mode)?;
1064                }
1065                s.push(']');
1066            }
1067        },
1068        (RenderMode::Normal, DataType::Intersection(parts)) => {
1069            for (idx, ty) in parts.iter().enumerate() {
1070                if idx != 0 {
1071                    s.push_str(" & ");
1072                }
1073
1074                let needs_parentheses = parts.len() > 1 && intersection_part_is_union(ty);
1075                if needs_parentheses {
1076                    s.push('(');
1077                }
1078                render_datatype(s, ctx, ty, location.clone(), mode)?;
1079                if needs_parentheses {
1080                    s.push(')');
1081                }
1082            }
1083        }
1084        (RenderMode::ShallowInline, DataType::Intersection(parts)) => intersection_dt(
1085            s,
1086            ctx.exporter,
1087            ctx.format,
1088            ctx.types,
1089            parts,
1090            location,
1091            ctx.parent_name,
1092            ctx.prefix,
1093            ctx.generics,
1094            mode,
1095        )?,
1096        (RenderMode::Normal, DataType::Reference(r)) => reference_dt(
1097            s,
1098            ctx.exporter,
1099            ctx.format,
1100            ctx.types,
1101            r,
1102            location,
1103            ctx.prefix,
1104            ctx.generics,
1105        )?,
1106        (RenderMode::ShallowInline, DataType::Reference(r)) => match r {
1107            Reference::Named(r) => {
1108                let ty = named_reference_ty(ctx.types, r, &location)?;
1109                let reference_generics = named_reference_generics(r)?;
1110                let child_ctx = RenderCtx {
1111                    generics: reference_generics,
1112                    ..ctx
1113                };
1114                let inline_path = path_string(&location);
1115                render_datatype(s, child_ctx, ty, location, mode)
1116                    .map_err(|err| err.with_inline_trace(ctx.types.get(r), inline_path))?;
1117            }
1118            Reference::Opaque(_) => reference_dt(
1119                s,
1120                ctx.exporter,
1121                ctx.format,
1122                ctx.types,
1123                r,
1124                location,
1125                ctx.prefix,
1126                ctx.generics,
1127            )?,
1128        },
1129    }
1130
1131    Ok(())
1132}
1133
1134fn needs_array_parens(ty: &str) -> bool {
1135    ty.contains(' ') && (!ty.ends_with('}') || ty.contains('&') || ty.contains('|'))
1136}
1137
1138fn push_list(s: &mut String, ty: &str, length: Option<usize>) {
1139    let ty = if needs_array_parens(ty) {
1140        Cow::Owned(format!("({ty})"))
1141    } else {
1142        Cow::Borrowed(ty)
1143    };
1144
1145    if let Some(length) = length {
1146        s.push('[');
1147        for i in 0..length {
1148            if i != 0 {
1149                s.push_str(", ");
1150            }
1151            s.push_str(&ty);
1152        }
1153        s.push(']');
1154    } else {
1155        s.push_str(&ty);
1156        s.push_str("[]");
1157    }
1158}
1159
1160fn intersection_part_is_union(ty: &DataType) -> bool {
1161    match ty {
1162        DataType::Nullable(_) => true,
1163        DataType::Enum(enm) => enm.variants.len() > 1,
1164        DataType::Intersection(parts) if parts.len() == 1 => intersection_part_is_union(&parts[0]),
1165        _ => false,
1166    }
1167}
1168
1169fn push_nullable(s: &mut String, inner: &str) {
1170    s.push_str(inner);
1171    if inner != NULL && !inner.ends_with(" | null") {
1172        s.push_str(" | null");
1173    }
1174}
1175
1176fn is_exhaustive_map_key(
1177    dt: &DataType,
1178    types: &Types,
1179    generics: &[(GenericReference, DataType)],
1180) -> bool {
1181    match dt {
1182        DataType::Enum(e) => e.variants.iter().filter(|(_, v)| !v.skip).count() == 0,
1183        DataType::Reference(Reference::Named(r)) => named_reference_ty(types, r, &[])
1184            .and_then(|ty| {
1185                named_reference_generics(r).map(|reference_generics| {
1186                    let substitution = generics
1187                        .iter()
1188                        .map(|(generic, dt)| (generic.name().clone(), dt.clone()))
1189                        .collect::<GenericSubst>();
1190                    let reference_generics = reference_generics
1191                        .iter()
1192                        .map(|(generic, dt)| {
1193                            let mut dt = dt.clone();
1194                            let _ = substitute_generics(&mut dt, &substitution);
1195                            (generic.clone(), dt)
1196                        })
1197                        .collect::<Vec<_>>();
1198                    is_exhaustive_map_key(ty, types, &reference_generics)
1199                })
1200            })
1201            .unwrap_or(false),
1202        DataType::Struct(strct) => match &strct.fields {
1203            Fields::Unnamed(fields) => {
1204                let mut fields = fields.fields.iter().filter_map(|field| field.ty.as_ref());
1205                fields
1206                    .next()
1207                    .is_none_or(|dt| is_exhaustive_map_key(dt, types, generics))
1208                    && fields.next().is_none()
1209            }
1210            _ => true,
1211        },
1212        DataType::Reference(Reference::Opaque(_)) => false,
1213        DataType::Generic(generic) => generics
1214            .iter()
1215            .find(|(candidate, _)| candidate == generic)
1216            .is_none_or(|(_, dt)| {
1217                matches!(dt, DataType::Generic(candidate) if candidate == generic)
1218                    || is_exhaustive_map_key(dt, types, generics)
1219            }),
1220        _ => true,
1221    }
1222}
1223
1224fn render_map(
1225    s: &mut String,
1226    exporter: &Exporter,
1227    format: Option<&dyn Format>,
1228    types: &Types,
1229    map: &Map,
1230    location: Vec<Cow<'static, str>>,
1231    parent_name: Option<&str>,
1232    prefix: &str,
1233    generics: &[(GenericReference, DataType)],
1234    value_mode: RenderMode,
1235) -> Result<(), Error> {
1236    let path = map_key_path(&location);
1237    map_keys::validate_map_key(map.key_ty(), types, format!("{path}.<map_key>"))?;
1238
1239    let rendered_key = map_key_render_type(map.key_ty().clone());
1240    let exhaustive = is_exhaustive_map_key(&rendered_key, types, generics);
1241
1242    // Use `{ [key in K]: V }` instead of `Record<K, V>` to avoid circular reference issues.
1243    if !exhaustive {
1244        s.push_str("Partial<");
1245    }
1246
1247    s.push_str("{ [key in ");
1248    map_key_datatype(
1249        s,
1250        exporter,
1251        format,
1252        types,
1253        &rendered_key,
1254        location.clone(),
1255        parent_name,
1256        prefix,
1257        generics,
1258    )?;
1259    s.push_str("]: ");
1260    value_mode.render(
1261        s,
1262        RenderCtx {
1263            exporter,
1264            format,
1265            types,
1266            parent_name,
1267            prefix,
1268            generics,
1269        },
1270        map.value_ty(),
1271        location,
1272    )?;
1273    s.push_str(" }");
1274
1275    if !exhaustive {
1276        s.push('>');
1277    }
1278
1279    Ok(())
1280}
1281
1282fn shallow_inline_datatype(
1283    s: &mut String,
1284    exporter: &Exporter,
1285    format: Option<&dyn Format>,
1286    types: &Types,
1287    dt: &DataType,
1288    location: Vec<Cow<'static, str>>,
1289    parent_name: Option<&str>,
1290    prefix: &str,
1291    generics: &[(GenericReference, DataType)],
1292) -> Result<(), Error> {
1293    render_datatype(
1294        s,
1295        RenderCtx {
1296            exporter,
1297            format,
1298            types,
1299            parent_name,
1300            prefix,
1301            generics,
1302        },
1303        dt,
1304        location,
1305        RenderMode::ShallowInline,
1306    )
1307}
1308
1309fn intersection_dt(
1310    s: &mut String,
1311    exporter: &Exporter,
1312    format: Option<&dyn Format>,
1313    types: &Types,
1314    parts: &[DataType],
1315    location: Vec<Cow<'static, str>>,
1316    parent_name: Option<&str>,
1317    prefix: &str,
1318    generics: &[(GenericReference, DataType)],
1319    mode: RenderMode,
1320) -> Result<(), Error> {
1321    let mut rendered = Vec::with_capacity(parts.len());
1322    for part in parts {
1323        let mut out = String::new();
1324        mode.render_intersection_part(
1325            &mut out,
1326            RenderCtx {
1327                exporter,
1328                format,
1329                types,
1330                parent_name,
1331                prefix,
1332                generics,
1333            },
1334            part,
1335            location.clone(),
1336        )?;
1337        rendered.push(format!("({out})"));
1338    }
1339
1340    s.push_str(&rendered.join(" & "));
1341    Ok(())
1342}
1343
1344// Render an anonymous type while expanding core-provided inline references.
1345fn inline_datatype(
1346    s: &mut String,
1347    exporter: &Exporter,
1348    format: Option<&dyn Format>,
1349    types: &Types,
1350    dt: &DataType,
1351    location: Vec<Cow<'static, str>>,
1352    parent_name: Option<&str>,
1353    prefix: &str,
1354    depth: usize,
1355    generics: &[(GenericReference, DataType)],
1356) -> Result<(), Error> {
1357    // Prevent infinite recursion
1358    if depth == 25 {
1359        return Err(Error::inline_recursion_limit_exceeded(path_string(
1360            &location,
1361        )));
1362    }
1363
1364    match dt {
1365        DataType::Primitive(p) => s.push_str(primitive_dt(p, location)?),
1366        DataType::Generic(g) => write_generic_reference(s, g),
1367        DataType::List(l) => {
1368            let mut dt_str = String::new();
1369            datatype(
1370                &mut dt_str,
1371                exporter,
1372                format,
1373                types,
1374                &l.ty,
1375                location.clone(),
1376                parent_name,
1377                prefix,
1378                generics,
1379            )?;
1380            push_list(s, &dt_str, l.length);
1381        }
1382        DataType::Map(m) => map_dt(s, exporter, format, types, m, location, generics)?,
1383        DataType::Nullable(def) => {
1384            let mut inner = String::new();
1385            inline_datatype(
1386                &mut inner,
1387                exporter,
1388                format,
1389                types,
1390                def,
1391                location,
1392                parent_name,
1393                "",
1394                depth + 1,
1395                generics,
1396            )?;
1397
1398            push_nullable(s, &inner);
1399        }
1400        DataType::Struct(st) => {
1401            if !generics.is_empty() {
1402                inline_struct_with_generics(
1403                    s,
1404                    exporter,
1405                    format,
1406                    types,
1407                    st,
1408                    location,
1409                    parent_name,
1410                    prefix,
1411                    depth,
1412                    generics,
1413                )?;
1414            } else {
1415                struct_dt(
1416                    s,
1417                    exporter,
1418                    format,
1419                    types,
1420                    st,
1421                    location,
1422                    parent_name,
1423                    prefix,
1424                    generics,
1425                )?;
1426            }
1427        }
1428        DataType::Enum(e) => enum_dt(s, exporter, types, e, location, prefix, generics)?,
1429        DataType::Tuple(t) => tuple_dt(s, exporter, types, t, location, generics)?,
1430        DataType::Intersection(types_) => intersection_dt(
1431            s,
1432            exporter,
1433            format,
1434            types,
1435            types_,
1436            location,
1437            parent_name,
1438            prefix,
1439            generics,
1440            RenderMode::Normal,
1441        )?,
1442        DataType::Reference(r) => {
1443            if let Reference::Named(r) = r
1444                && let Ok(ty) = named_reference_ty(types, r, &location)
1445            {
1446                let reference_generics = named_reference_generics(r)?;
1447                let inline_path = path_string(&location);
1448                inline_datatype(
1449                    s,
1450                    exporter,
1451                    format,
1452                    types,
1453                    ty,
1454                    location,
1455                    parent_name,
1456                    prefix,
1457                    depth + 1,
1458                    reference_generics,
1459                )
1460                .map_err(|err| err.with_inline_trace(types.get(r), inline_path))?;
1461            } else {
1462                reference_dt(s, exporter, format, types, r, location, prefix, generics)?;
1463            }
1464        }
1465    }
1466
1467    Ok(())
1468}
1469
1470fn inline_struct_with_generics(
1471    s: &mut String,
1472    exporter: &Exporter,
1473    format: Option<&dyn Format>,
1474    types: &Types,
1475    st: &Struct,
1476    location: Vec<Cow<'static, str>>,
1477    parent_name: Option<&str>,
1478    prefix: &str,
1479    depth: usize,
1480    generics: &[(GenericReference, DataType)],
1481) -> Result<(), Error> {
1482    match &st.fields {
1483        Fields::Unit => s.push_str(NULL),
1484        Fields::Unnamed(_) => struct_dt(
1485            s,
1486            exporter,
1487            format,
1488            types,
1489            st,
1490            location,
1491            parent_name,
1492            prefix,
1493            generics,
1494        )?,
1495        Fields::Named(named) => {
1496            s.push('{');
1497            let mut has_field = false;
1498
1499            for (key, field) in &named.fields {
1500                let Some(field_ty) = field.ty.as_ref() else {
1501                    continue;
1502                };
1503
1504                has_field = true;
1505                s.push('\n');
1506                s.push_str(prefix);
1507                s.push('\t');
1508                s.push_str(&sanitise_key(key.clone(), false));
1509                if field.optional {
1510                    s.push('?');
1511                }
1512                s.push_str(": ");
1513                inline_datatype(
1514                    s,
1515                    exporter,
1516                    format,
1517                    types,
1518                    field_ty,
1519                    location.clone(),
1520                    parent_name,
1521                    prefix,
1522                    depth + 1,
1523                    generics,
1524                )?;
1525                s.push(',');
1526            }
1527
1528            if has_field {
1529                s.push('\n');
1530                s.push_str(prefix);
1531            }
1532
1533            s.push('}');
1534        }
1535    }
1536
1537    Ok(())
1538}
1539
1540pub(crate) fn datatype(
1541    s: &mut String,
1542    exporter: &Exporter,
1543    format: Option<&dyn Format>,
1544    types: &Types,
1545    dt: &DataType,
1546    location: Vec<Cow<'static, str>>,
1547    parent_name: Option<&str>,
1548    prefix: &str,
1549    generics: &[(GenericReference, DataType)],
1550) -> Result<(), Error> {
1551    render_datatype(
1552        s,
1553        RenderCtx {
1554            exporter,
1555            format,
1556            types,
1557            parent_name,
1558            prefix,
1559            generics,
1560        },
1561        dt,
1562        location,
1563        RenderMode::Normal,
1564    )
1565}
1566
1567fn primitive_dt(p: &Primitive, location: Vec<Cow<'static, str>>) -> Result<&'static str, Error> {
1568    use Primitive::*;
1569
1570    Ok(match p {
1571        i8 | i16 | i32 | u8 | u16 | u32 => "number",
1572        // `null` comes from `NaN`, `Infinity` and `-Infinity`. Is done by JS APIs and Serde JSON.
1573        f16 | f32 | f64 /* this looks wrong but `f64` is the direct equivalent of `number` */ => "number | null",
1574        usize | isize | i64 | u64 | i128 | u128 | f128 => {
1575            return Err(Error::bigint_forbidden(location.join(".")));
1576        }
1577        Primitive::bool => "boolean",
1578        str | char => "string",
1579    })
1580}
1581
1582fn list_dt(
1583    s: &mut String,
1584    exporter: &Exporter,
1585    types: &Types,
1586    l: &List,
1587    location: Vec<Cow<'static, str>>,
1588    generics: &[(GenericReference, DataType)],
1589) -> Result<(), Error> {
1590    let mut dt = String::new();
1591    datatype(
1592        &mut dt, exporter, None, types, &l.ty, location, None, "", generics,
1593    )?;
1594    push_list(s, &dt, l.length);
1595
1596    Ok(())
1597}
1598
1599fn map_key_datatype(
1600    s: &mut String,
1601    exporter: &Exporter,
1602    format: Option<&dyn Format>,
1603    types: &Types,
1604    key_ty: &DataType,
1605    location: Vec<Cow<'static, str>>,
1606    parent_name: Option<&str>,
1607    prefix: &str,
1608    generics: &[(GenericReference, DataType)],
1609) -> Result<(), Error> {
1610    match key_ty {
1611        DataType::Reference(r) => {
1612            reference_dt(s, exporter, format, types, r, location, prefix, generics)
1613        }
1614        key_ty => shallow_inline_datatype(
1615            s,
1616            exporter,
1617            format,
1618            types,
1619            key_ty,
1620            location,
1621            parent_name,
1622            prefix,
1623            generics,
1624        ),
1625    }
1626}
1627
1628fn map_dt(
1629    s: &mut String,
1630    exporter: &Exporter,
1631    format: Option<&dyn Format>,
1632    types: &Types,
1633    m: &Map,
1634    location: Vec<Cow<'static, str>>,
1635    generics: &[(GenericReference, DataType)],
1636) -> Result<(), Error> {
1637    render_map(
1638        s,
1639        exporter,
1640        format,
1641        types,
1642        m,
1643        location,
1644        None,
1645        "",
1646        generics,
1647        RenderMode::Normal,
1648    )
1649}
1650
1651fn map_key_path(location: &[Cow<'static, str>]) -> String {
1652    if location.is_empty() {
1653        return "HashMap".to_string();
1654    }
1655
1656    location.join(".")
1657}
1658
1659fn map_key_render_type(dt: DataType) -> DataType {
1660    if matches!(dt, DataType::Primitive(Primitive::bool)) {
1661        return bool_key_literal_datatype();
1662    }
1663
1664    dt
1665}
1666
1667fn bool_key_literal_datatype() -> DataType {
1668    let mut bool_enum = Enum::default();
1669    bool_enum
1670        .variants
1671        .push((Cow::Borrowed("true"), Variant::unit()));
1672    bool_enum
1673        .variants
1674        .push((Cow::Borrowed("false"), Variant::unit()));
1675    DataType::Enum(bool_enum)
1676}
1677
1678fn unnamed_fields_datatype(
1679    s: &mut String,
1680    exporter: &Exporter,
1681    format: Option<&dyn Format>,
1682    types: &Types,
1683    fields: &[(&Field, &DataType)],
1684    declared_len: usize,
1685    location: Vec<Cow<'static, str>>,
1686    parent_name: Option<&str>,
1687    prefix: &str,
1688    generics: &[(GenericReference, DataType)],
1689    force_inline: bool,
1690) -> Result<(), Error> {
1691    match fields {
1692        // Only a genuine newtype (declared arity 1) renders as its bare
1693        // inner type. A declared-multi-field tuple reduced to one live
1694        // element by skips stays a sequence on the wire, so it keeps the
1695        // array form below.
1696        [(field, ty)] if declared_len == 1 => {
1697            let mut v = String::new();
1698            datatype_with_inline_attr(
1699                &mut v,
1700                exporter,
1701                format,
1702                types,
1703                ty,
1704                location,
1705                parent_name,
1706                "",
1707                generics,
1708                force_inline,
1709            )?;
1710            s.push_str(&inner_comments(
1711                field.deprecated.as_ref(),
1712                &field.docs,
1713                v,
1714                true,
1715                prefix,
1716            ));
1717        }
1718        fields => {
1719            let optional_from = trailing_optional_run(fields.iter().map(|(field, _)| *field));
1720
1721            s.push('[');
1722            for (i, (field, ty)) in fields.iter().enumerate() {
1723                if i != 0 {
1724                    s.push_str(", ");
1725                }
1726
1727                let mut v = String::new();
1728                let mut field_location = location.clone();
1729                field_location.push(i.to_string().into());
1730                datatype_with_inline_attr(
1731                    &mut v,
1732                    exporter,
1733                    format,
1734                    types,
1735                    ty,
1736                    field_location,
1737                    parent_name,
1738                    "",
1739                    generics,
1740                    force_inline,
1741                )?;
1742                if i >= optional_from {
1743                    if optional_element_needs_parens(ty) {
1744                        v = format!("({v})");
1745                    }
1746                    v.push('?');
1747                }
1748                s.push_str(&inner_comments(
1749                    field.deprecated.as_ref(),
1750                    &field.docs,
1751                    v,
1752                    true,
1753                    prefix,
1754                ));
1755            }
1756            s.push(']');
1757        }
1758    }
1759
1760    Ok(())
1761}
1762
1763/// The start index of the maximal all-optional TRAILING run of tuple
1764/// elements, or `fields.len()` when the last element isn't optional.
1765///
1766/// TypeScript only allows optional tuple elements at the end, so a
1767/// non-trailing `optional` flag is ignored (rendered required, the safe
1768/// wider-input direction). serde can't produce one anyway: `#[serde(default)]`
1769/// on an unnamed field requires every later field to be defaulted too, so
1770/// optional elements always form a suffix.
1771fn trailing_optional_run<'a>(fields: impl Iterator<Item = &'a Field>) -> usize {
1772    let mut run_start = 0;
1773    for (idx, field) in fields.enumerate() {
1774        if !field.optional {
1775            run_start = idx + 1;
1776        }
1777    }
1778    run_start
1779}
1780
1781/// Whether an optional tuple element's type must be parenthesized before the
1782/// trailing `?` marker: `[number, (number | null)?]` is valid TypeScript
1783/// while `[number, number | null?]` is not, and likewise for intersections.
1784fn optional_element_needs_parens(ty: &DataType) -> bool {
1785    match ty {
1786        DataType::Nullable(_) | DataType::Enum(_) | DataType::Intersection(_) => true,
1787        DataType::Reference(Reference::Named(reference)) => match &reference.inner {
1788            NamedReferenceType::Inline { dt, .. } => optional_element_needs_parens(dt),
1789            _ => false,
1790        },
1791        // A lone live unnamed field renders as its bare inner type.
1792        DataType::Struct(strct) => match &strct.fields {
1793            Fields::Unnamed(unnamed) if unnamed.fields.len() == 1 => unnamed
1794                .fields
1795                .first()
1796                .and_then(|field| field.ty.as_ref())
1797                .is_some_and(optional_element_needs_parens),
1798            _ => false,
1799        },
1800        _ => false,
1801    }
1802}
1803
1804fn struct_dt(
1805    s: &mut String,
1806    exporter: &Exporter,
1807    format: Option<&dyn Format>,
1808    types: &Types,
1809    st: &Struct,
1810    location: Vec<Cow<'static, str>>,
1811    parent_name: Option<&str>,
1812    prefix: &str,
1813    generics: &[(GenericReference, DataType)],
1814) -> Result<(), Error> {
1815    match &st.fields {
1816        Fields::Unit => s.push_str(NULL),
1817        Fields::Unnamed(unnamed) => unnamed_fields_datatype(
1818            s,
1819            exporter,
1820            format,
1821            types,
1822            &unnamed
1823                .fields
1824                .iter()
1825                .filter_map(|field| field.ty.as_ref().map(|ty| (field, ty)))
1826                .collect::<Vec<_>>(),
1827            unnamed.fields.len(),
1828            location,
1829            parent_name,
1830            prefix,
1831            generics,
1832            false,
1833        )?,
1834        Fields::Named(named) => {
1835            let fields = named
1836                .fields
1837                .iter()
1838                .filter_map(|(name, field)| field.ty.as_ref().map(|ty| (name, (field, ty))))
1839                .collect::<Vec<_>>();
1840
1841            if fields.is_empty() {
1842                s.push_str("Record<string, never>");
1843                return Ok(());
1844            }
1845
1846            let mut unflattened_fields = Vec::with_capacity(fields.len());
1847            for (key, (field, ty)) in fields {
1848                let field_prefix = format!("{prefix}\t");
1849                let mut other = String::new();
1850                let mut field_location = location.clone();
1851                field_location.push(key.clone());
1852                object_field_to_ts(
1853                    &mut other,
1854                    exporter,
1855                    format,
1856                    types,
1857                    key.clone(),
1858                    (field, ty),
1859                    field_location,
1860                    parent_name,
1861                    generics,
1862                    &field_prefix,
1863                    false,
1864                    None,
1865                )?;
1866
1867                unflattened_fields.push(inner_comments(
1868                    field.deprecated.as_ref(),
1869                    &field.docs,
1870                    other,
1871                    false,
1872                    &field_prefix,
1873                ));
1874            }
1875
1876            s.push('{');
1877            for field in unflattened_fields {
1878                s.push('\n');
1879                s.push_str(&field);
1880                s.push(',');
1881            }
1882            s.push('\n');
1883            s.push_str(prefix);
1884            s.push('}');
1885        }
1886    }
1887
1888    Ok(())
1889}
1890
1891fn object_field_to_ts(
1892    s: &mut String,
1893    exporter: &Exporter,
1894    format: Option<&dyn Format>,
1895    types: &Types,
1896    key: Cow<'static, str>,
1897    (field, ty): (&Field, &DataType),
1898    location: Vec<Cow<'static, str>>,
1899    parent_name: Option<&str>,
1900    generics: &[(GenericReference, DataType)],
1901    prefix: &str,
1902    force_inline: bool,
1903    ty_override: Option<&str>,
1904) -> Result<(), Error> {
1905    let field_name_safe = sanitise_key(key, false);
1906    let key = if field.optional {
1907        format!("{field_name_safe}?").into()
1908    } else {
1909        field_name_safe
1910    };
1911
1912    let value = match ty_override {
1913        Some(ty_override) => ty_override.to_string(),
1914        None => {
1915            let mut value = String::new();
1916            datatype_with_inline_attr(
1917                &mut value,
1918                exporter,
1919                format,
1920                types,
1921                ty,
1922                location,
1923                parent_name,
1924                prefix,
1925                generics,
1926                force_inline,
1927            )?;
1928            value
1929        }
1930    };
1931
1932    s.push_str(prefix);
1933    s.push_str(&key);
1934    s.push_str(": ");
1935    s.push_str(&value);
1936
1937    Ok(())
1938}
1939
1940struct EnumVariantOutput {
1941    value: String,
1942    strict_keys: Option<BTreeSet<String>>,
1943}
1944
1945#[derive(Debug, Clone)]
1946struct DiscriminatorAnalysis {
1947    key: String,
1948    known_literals: Vec<String>,
1949    fallback_variant_idx: Option<usize>,
1950}
1951
1952#[derive(Debug, Clone, Copy)]
1953struct VariantTypeOverride<'a> {
1954    key: &'a str,
1955    ty: &'a str,
1956}
1957
1958#[derive(Debug, Clone)]
1959enum DiscriminatorValue {
1960    StringLiteral(String),
1961    String,
1962}
1963
1964fn analyze_discriminator(
1965    variants: &[&(Cow<'static, str>, Variant)],
1966) -> Option<DiscriminatorAnalysis> {
1967    if variants.iter().any(|(name, _)| name.is_empty()) {
1968        return None;
1969    }
1970
1971    let mut key = None::<String>;
1972    let mut known_literals = BTreeSet::new();
1973    let mut fallback_variant_idx = None;
1974
1975    for (idx, (_, variant)) in variants.iter().enumerate() {
1976        let (variant_key, value) = variant_discriminator(variant)?;
1977
1978        if let Some(expected) = &key {
1979            if expected != &variant_key {
1980                return None;
1981            }
1982        } else {
1983            key = Some(variant_key.clone());
1984        }
1985
1986        match value {
1987            DiscriminatorValue::StringLiteral(value) => {
1988                known_literals.insert(value);
1989            }
1990            DiscriminatorValue::String => {
1991                if fallback_variant_idx.replace(idx).is_some() {
1992                    return None;
1993                }
1994            }
1995        }
1996    }
1997
1998    if known_literals.is_empty() {
1999        return None;
2000    }
2001
2002    Some(DiscriminatorAnalysis {
2003        key: key?,
2004        known_literals: known_literals.into_iter().collect(),
2005        fallback_variant_idx,
2006    })
2007}
2008
2009fn variant_discriminator(variant: &Variant) -> Option<(String, DiscriminatorValue)> {
2010    let Fields::Named(named) = &variant.fields else {
2011        return None;
2012    };
2013
2014    let (name, field) = named.fields.iter().find(|(_, field)| !field.optional)?;
2015    let ty = field.ty.as_ref()?;
2016
2017    if matches!(ty, DataType::Primitive(Primitive::str)) {
2018        return Some((name.to_string(), DiscriminatorValue::String));
2019    }
2020
2021    string_literal_datatype_value(ty)
2022        .map(|value| (name.to_string(), DiscriminatorValue::StringLiteral(value)))
2023}
2024
2025fn string_literal_datatype_value(ty: &DataType) -> Option<String> {
2026    let DataType::Enum(enm) = ty else {
2027        return None;
2028    };
2029
2030    let mut variants = enm.variants.iter();
2031    let (name, variant) = variants.next()?;
2032    if variants.next().is_some() || !matches!(&variant.fields, Fields::Unit) {
2033        return None;
2034    }
2035
2036    Some(name.to_string())
2037}
2038
2039fn exclude_known_literals_type(literals: &[String]) -> Option<String> {
2040    if literals.is_empty() {
2041        return None;
2042    }
2043
2044    let known = literals
2045        .iter()
2046        .map(|value| format!("\"{}\"", escape_typescript_string_literal(value.as_str())))
2047        .collect::<Vec<_>>()
2048        .join(" | ");
2049
2050    Some(format!("Exclude<string, {known}>"))
2051}
2052
2053fn fallback_discriminator_override(
2054    discriminator: Option<&DiscriminatorAnalysis>,
2055) -> Option<(usize, &str, String)> {
2056    let discriminator = discriminator?;
2057    discriminator.fallback_variant_idx.and_then(|idx| {
2058        exclude_known_literals_type(&discriminator.known_literals)
2059            .map(|ty| (idx, discriminator.key.as_str(), ty))
2060    })
2061}
2062
2063fn untagged_strict_keys(variant: &Variant) -> Option<BTreeSet<String>> {
2064    match &variant.fields {
2065        Fields::Named(obj) => Some(
2066            obj.fields
2067                .iter()
2068                .filter_map(|(name, field)| {
2069                    field
2070                        .ty
2071                        .as_ref()
2072                        .map(|_| sanitise_key(name.clone(), false).to_string())
2073                })
2074                .collect(),
2075        ),
2076        _ => None,
2077    }
2078}
2079
2080fn has_anonymous_variant(variants: &[&(Cow<'static, str>, Variant)]) -> bool {
2081    variants.iter().any(|(name, _)| name.is_empty())
2082}
2083
2084fn active_variants(e: &Enum) -> Vec<&(Cow<'static, str>, Variant)> {
2085    e.variants
2086        .iter()
2087        .filter(|(_, variant)| !variant.skip)
2088        .collect()
2089}
2090
2091fn strictify_enum_variants(variants: &mut [EnumVariantOutput]) {
2092    let strict_key_universe = variants
2093        .iter()
2094        .filter_map(|variant| variant.strict_keys.as_ref())
2095        .flat_map(|keys| keys.iter().cloned())
2096        .collect::<BTreeSet<_>>();
2097
2098    if strict_key_universe.len() < 2 {
2099        return;
2100    }
2101
2102    for variant in variants {
2103        let Some(keys) = variant.strict_keys.as_ref() else {
2104            continue;
2105        };
2106
2107        let missing_keys = strict_key_universe
2108            .iter()
2109            .filter(|key| !keys.contains(*key))
2110            .map(|key| format!("{key}?: {NEVER}"))
2111            .collect::<Vec<_>>();
2112
2113        if !missing_keys.is_empty() {
2114            variant.value = format!("({}) & {{ {} }}", variant.value, missing_keys.join("; "));
2115        }
2116    }
2117}
2118
2119fn push_union(s: &mut String, variants: Vec<String>) {
2120    let mut seen = BTreeSet::new();
2121    let variants = variants
2122        .into_iter()
2123        .filter(|variant| seen.insert(variant.clone()))
2124        .collect::<Vec<_>>();
2125
2126    if variants.is_empty() {
2127        s.push_str(NEVER);
2128    } else {
2129        s.push_str(&variants.join(" | "));
2130    }
2131}
2132
2133fn enum_variant_datatype(
2134    exporter: &Exporter,
2135    format: Option<&dyn Format>,
2136    types: &Types,
2137    name: Cow<'static, str>,
2138    variant: &Variant,
2139    location: Vec<Cow<'static, str>>,
2140    prefix: &str,
2141    generics: &[(GenericReference, DataType)],
2142    ty_override: Option<VariantTypeOverride<'_>>,
2143) -> Result<Option<String>, Error> {
2144    match &variant.fields {
2145        Fields::Unit if name.is_empty() => Err(Error::unsupported_anonymous_enum_variant(
2146            path_string(&location),
2147            "unit",
2148        )),
2149        Fields::Unit => Ok(Some(sanitise_key(name, true).to_string())),
2150        Fields::Named(_) if name.is_empty() => Err(Error::unsupported_anonymous_enum_variant(
2151            path_string(&location),
2152            "named-field",
2153        )),
2154        Fields::Named(obj) => {
2155            let mut regular_fields = Vec::new();
2156            for (field_name, field) in &obj.fields {
2157                let Some(ty) = field.ty.as_ref() else {
2158                    continue;
2159                };
2160
2161                let mut other = String::new();
2162                let mut field_location = location.clone();
2163                if field_location
2164                    .last()
2165                    .is_some_and(|location| location == field_name)
2166                {
2167                    if !matches!(ty, DataType::Struct(_)) {
2168                        field_location.push("0".into());
2169                    }
2170                } else {
2171                    field_location.push(field_name.clone());
2172                }
2173                object_field_to_ts(
2174                    &mut other,
2175                    exporter,
2176                    format,
2177                    types,
2178                    field_name.clone(),
2179                    (field, ty),
2180                    field_location,
2181                    None,
2182                    generics,
2183                    "",
2184                    false,
2185                    ty_override
2186                        .as_ref()
2187                        .filter(|override_ty| override_ty.key == field_name.as_ref())
2188                        .map(|override_ty| override_ty.ty),
2189                )?;
2190
2191                regular_fields.push(inner_comments(
2192                    field.deprecated.as_ref(),
2193                    &field.docs,
2194                    other,
2195                    true,
2196                    prefix,
2197                ));
2198            }
2199
2200            Ok(Some(if regular_fields.is_empty() {
2201                format!("Record<{STRING}, {NEVER}>")
2202            } else {
2203                format!("{{ {} }}", regular_fields.join("; "))
2204            }))
2205        }
2206        Fields::Unnamed(obj) => {
2207            let live_fields = obj
2208                .fields
2209                .iter()
2210                .filter_map(|field| field.ty.as_ref().map(|ty| (field, ty)))
2211                .collect::<Vec<_>>();
2212            // A newtype payload renders bare, with no tuple position for an
2213            // optional marker to attach to.
2214            let is_newtype = obj.fields.len() == 1;
2215            let optional_from = if is_newtype {
2216                live_fields.len()
2217            } else {
2218                trailing_optional_run(live_fields.iter().map(|(field, _)| *field))
2219            };
2220
2221            let fields = live_fields
2222                .iter()
2223                .enumerate()
2224                .map(|(idx, (_, ty))| {
2225                    let mut out = String::new();
2226                    let mut field_location = location.clone();
2227                    field_location.push(idx.to_string().into());
2228                    datatype_with_inline_attr(
2229                        &mut out,
2230                        exporter,
2231                        format,
2232                        types,
2233                        ty,
2234                        field_location,
2235                        None,
2236                        "",
2237                        generics,
2238                        false,
2239                    )?;
2240                    if idx >= optional_from {
2241                        if optional_element_needs_parens(ty) {
2242                            out = format!("({out})");
2243                        }
2244                        out.push('?');
2245                    }
2246                    Ok(out)
2247                })
2248                .collect::<Result<Vec<_>, Error>>()?;
2249
2250            Ok(match &fields[..] {
2251                [] if obj.fields.is_empty() => Some("[]".to_string()),
2252                [] => None,
2253                [field] if is_newtype => Some(field.to_string()),
2254                fields => Some(format!("[{}]", fields.join(", "))),
2255            })
2256        }
2257    }
2258}
2259
2260/// Identity used to detect Serde `#[serde(untagged)]` alias cycles among
2261/// named exports (see [`collapse_untagged_alias_cycle`]). Two named types
2262/// are treated as "the same" if they share a Rust path, mirroring the
2263/// identity `rust_type_path` already uses for name-collision detection
2264/// elsewhere in this module.
2265///
2266/// Generic arguments are deliberately ignored: TypeScript's alias-cycle
2267/// check (TS2456) is declaration-level, so `GenP<T>` referencing `GenQ<T>`
2268/// referencing `GenP<U>` back is a cycle no matter how the parameters are
2269/// instantiated along the way.
2270type AliasId = (Cow<'static, str>, Cow<'static, str>);
2271
2272fn alias_id(ndt: &NamedDataType) -> AliasId {
2273    (ndt.module_path.clone(), ndt.name.clone())
2274}
2275
2276/// Substitution from a cycle member's generic parameter names to the
2277/// datatypes they are instantiated with along some path from the exported
2278/// root type: either the root's own parameters (`GenQ<U>` in a cycle with
2279/// `GenP<T>` says `U` where `GenP`'s body must say `T`) or concrete types
2280/// (`Root -> Gen<String> -> Root` instantiates `Gen`'s `T` at `string`).
2281/// Applying it to a member's variants lets them merge into the root's body
2282/// without dangling parameters.
2283type GenericSubst = HashMap<Cow<'static, str>, DataType>;
2284
2285/// An alias-transparent hop from one named export to another: a position
2286/// whose TypeScript rendering is the *bare* referenced name (possibly
2287/// `| null`), with no `{}`/`[]`/tuple wrapper deferring alias resolution
2288/// around it.
2289///
2290/// This is exactly the shape that makes TypeScript's alias resolution
2291/// eager: `type X = Y` requires resolving `Y` immediately, so a cycle
2292/// through only this shape of hop is what produces the illegal
2293/// `type X = X | ...` (TS2456), whereas a cycle through `{ field: X }`,
2294/// `X[]`, or `[X]` does not, because object/array/tuple types defer
2295/// resolution.
2296struct TransparentHop<'t, 'r> {
2297    target: &'t NamedDataType,
2298    /// The reference itself, kept so the collapse can resolve the hop's
2299    /// generic arguments (including omitted ones) with the same
2300    /// [`resolved_reference_generics`] logic ordinary rendering uses.
2301    reference: &'r NamedReference,
2302    /// Whether the hop passes through [`DataType::Nullable`], rendering as
2303    /// `Target | null`. A union member is still resolved eagerly, so this is
2304    /// just as much a cycle - but dropping such a back edge must keep the
2305    /// `null` branch, because an `Option` chain bottoms out at `None`.
2306    nullable: bool,
2307}
2308
2309/// Returns the alias-transparent hop `dt` renders as, if any: a bare
2310/// [`Reference::Named`], optionally wrapped in [`DataType::Nullable`]
2311/// (which renders as `| null` - still an eagerly-resolved position).
2312///
2313/// The hop's target borrows from `types` while the reference borrows from
2314/// `dt`, so a hop derived from a temporary (substituted) datatype still
2315/// yields a long-lived target.
2316fn transparent_reference<'t, 'r>(
2317    types: &'t Types,
2318    dt: &'r DataType,
2319) -> Option<TransparentHop<'t, 'r>> {
2320    match dt {
2321        DataType::Reference(Reference::Named(r)) => match &r.inner {
2322            NamedReferenceType::Reference { .. } => Some(TransparentHop {
2323                target: types.get(r)?,
2324                reference: r,
2325                nullable: false,
2326            }),
2327            _ => None,
2328        },
2329        DataType::Nullable(inner) => Some(TransparentHop {
2330            nullable: true,
2331            ..transparent_reference(types, inner)?
2332        }),
2333        _ => None,
2334    }
2335}
2336
2337/// If `variant`'s rendering is exactly a transparent hop - the shape a
2338/// `#[serde(untagged)]` newtype variant takes, since serde adds no wire
2339/// structure around it - returns that hop.
2340///
2341/// The variant must have exactly one field *total*, mirroring the
2342/// `obj.fields.len() == 1` bare-rendering rule in [`enum_variant_datatype`]:
2343/// a skipped extra field makes the renderer emit a `[T]` one-tuple
2344/// (matching serde's one-element seq), which is a deferred position and
2345/// therefore not transparent.
2346fn transparent_variant_hop<'t, 'r>(
2347    types: &'t Types,
2348    variant: &'r Variant,
2349) -> Option<TransparentHop<'t, 'r>> {
2350    let Fields::Unnamed(unnamed) = &variant.fields else {
2351        return None;
2352    };
2353    let [field] = unnamed.fields.as_slice() else {
2354        return None;
2355    };
2356    transparent_reference(types, field.ty.as_ref()?)
2357}
2358
2359/// Returns the alias-transparent *position* of a named export whose own
2360/// body renders as a single bare datatype - a plain alias body (e.g. what
2361/// `#[serde(transparent)]` rewrites to) or a newtype tuple struct, both
2362/// exported as `export type W = <position>;`. Such exports continue an
2363/// alias cycle without contributing union members of their own.
2364///
2365/// Unlike enum variants, [`struct_dt`] pre-filters skipped fields, so a
2366/// struct renders bare whenever exactly one *live* field remains.
2367fn transparent_export_position(ndt: &NamedDataType) -> Option<&DataType> {
2368    match ndt.ty.as_ref()? {
2369        DataType::Struct(s) => {
2370            let Fields::Unnamed(unnamed) = &s.fields else {
2371                return None;
2372            };
2373            let mut live = unnamed.fields.iter().filter_map(|field| field.ty.as_ref());
2374            let ty = live.next()?;
2375            if live.next().is_some() {
2376                return None;
2377            }
2378            Some(ty)
2379        }
2380        // An enum body contributes hops through its variants instead.
2381        DataType::Enum(_) => None,
2382        dt => Some(dt),
2383    }
2384}
2385
2386/// The transparent hop of a passthrough export's body, if any (see
2387/// [`transparent_export_position`]).
2388fn transparent_export_hop<'t, 'r>(
2389    types: &'t Types,
2390    ndt: &'r NamedDataType,
2391) -> Option<TransparentHop<'t, 'r>> {
2392    transparent_reference(types, transparent_export_position(ndt)?)
2393}
2394
2395/// A named export participating in the alias-transparent reference graph.
2396struct AliasNode<'a> {
2397    ndt: &'a NamedDataType,
2398    /// Every distinct generic instantiation this node is reached with from
2399    /// the root, in deterministic discovery order. A node can have several -
2400    /// e.g. `Gen<T>` reached both as itself (the root, identity) and as
2401    /// `Gen<String>` through the cycle - and its variants merge into the
2402    /// root's body once per instantiation.
2403    substs: Vec<GenericSubst>,
2404    /// Targets of this node's outgoing alias-transparent hops, used to
2405    /// compute cycle membership. The hops themselves are re-derived (and
2406    /// re-classified post-substitution) during the merge walk.
2407    edges: Vec<AliasId>,
2408}
2409
2410/// Divergence guards for [`collapse_untagged_alias_cycle`]'s instantiation
2411/// walk (see [`enqueue_cycle_instantiation`]). The instantiation set is
2412/// infinite only when the cycle grows its own type arguments
2413/// (`Gen<T> -> Gen<Vec<T>>`) - a shape serde itself cannot serialize (it
2414/// would need infinite monomorphization) but dynamically built [`Types`]
2415/// can express - so two generous caps turn divergence into an error
2416/// instead of a hang: one on *distinct* instantiations per node (breadth -
2417/// many small instantiations), and a much smaller one on the structural
2418/// size of a single instantiation (growth - it stops both linearly-growing
2419/// arguments after a few hundred trips and exponentially-growing ones like
2420/// `Gen<T> -> Gen<(T, T)>` long before they exhaust memory; no realistic
2421/// single type argument is 256 nodes deep/wide).
2422const MAX_SUBSTS_PER_NODE: usize = 1024;
2423const MAX_SUBST_WEIGHT: usize = 256;
2424
2425/// Adds a (cycle member, instantiation) pair to the collapse's merge work
2426/// list unless that exact pair was already processed, enforcing the
2427/// divergence guards above. Only genuinely new pairs enter the list, which
2428/// is what makes the walk a terminating fixed-point iteration.
2429fn enqueue_cycle_instantiation(
2430    nodes: &mut HashMap<AliasId, AliasNode<'_>>,
2431    work: &mut Vec<(AliasId, GenericSubst)>,
2432    target_id: AliasId,
2433    subst: GenericSubst,
2434) -> Result<(), Cow<'static, str>> {
2435    let node = nodes
2436        .get_mut(&target_id)
2437        .expect("cycle members were discovered in phase 1");
2438    if node.substs.contains(&subst) {
2439        return Ok(());
2440    }
2441    if node.substs.len() >= MAX_SUBSTS_PER_NODE {
2442        return Err(Cow::Borrowed(
2443            "the cycle's generic parameter instantiations do not converge (a single type is instantiated with an unbounded number of distinct arguments)",
2444        ));
2445    }
2446    if subst.values().map(datatype_weight).sum::<usize>() > MAX_SUBST_WEIGHT {
2447        return Err(Cow::Borrowed(
2448            "the cycle's generic parameter instantiations do not converge (a type argument keeps growing with every trip around the cycle)",
2449        ));
2450    }
2451    node.substs.push(subst.clone());
2452    work.push((target_id, subst));
2453    Ok(())
2454}
2455
2456fn node_enum(ndt: &NamedDataType) -> Option<&Enum> {
2457    match ndt.ty.as_ref() {
2458        Some(DataType::Enum(e)) => Some(e),
2459        _ => None,
2460    }
2461}
2462
2463/// The *effective* transparent hop of a position under an instantiation:
2464/// where its rendering ends up once generic parameters are taken into
2465/// account, which is how TypeScript's eager alias resolution sees it.
2466struct EffectiveHop<'t> {
2467    target: &'t NamedDataType,
2468    /// The full substitution the hop applies to its target, resolved via
2469    /// [`edge_substitution`].
2470    target_subst: GenericSubst,
2471    nullable: bool,
2472}
2473
2474/// Computes the effective transparent hop of an enum variant under `subst`,
2475/// along with the substituted variant to merge if the hop is not taken:
2476///
2477/// - A *raw* hop's target is fixed regardless of substitution; its
2478///   arguments resolve under `subst`.
2479/// - Otherwise the *substituted* form can reveal a hop that only exists
2480///   under this instantiation - `B(T)` with `T = Root` (or
2481///   `T = Gen<String>`). A revealed reference's arguments are already in
2482///   the root's scope, so they resolve under `identity`.
2483fn effective_variant_hop<'t>(
2484    types: &'t Types,
2485    variant: &Variant,
2486    subst: &GenericSubst,
2487    identity: &GenericSubst,
2488) -> Result<(Variant, Option<EffectiveHop<'t>>), Cow<'static, str>> {
2489    let raw_hop = transparent_variant_hop(types, variant);
2490
2491    let mut variant = variant.clone();
2492    substitute_fields_generics(&mut variant.fields, subst)?;
2493
2494    if let Some(hop) = raw_hop {
2495        let target_subst = edge_substitution(subst, hop.target, hop.reference)?;
2496        let hop = EffectiveHop {
2497            target: hop.target,
2498            target_subst,
2499            nullable: hop.nullable,
2500        };
2501        return Ok((variant, Some(hop)));
2502    }
2503
2504    let hop = match transparent_variant_hop(types, &variant) {
2505        Some(hop) => Some(EffectiveHop {
2506            target: hop.target,
2507            target_subst: edge_substitution(identity, hop.target, hop.reference)?,
2508            nullable: hop.nullable,
2509        }),
2510        None => None,
2511    };
2512    Ok((variant, hop))
2513}
2514
2515/// The passthrough-export analogue of [`effective_variant_hop`]: the
2516/// effective hop of the export's body position under `subst`, along with
2517/// the substituted position itself (a passthrough member whose effective
2518/// body is *not* a cycle hop contributes it as a terminal).
2519fn effective_export_hop<'t>(
2520    types: &'t Types,
2521    ndt: &NamedDataType,
2522    subst: &GenericSubst,
2523    identity: &GenericSubst,
2524) -> Result<Option<(DataType, Option<EffectiveHop<'t>>)>, Cow<'static, str>> {
2525    let Some(position) = transparent_export_position(ndt) else {
2526        return Ok(None);
2527    };
2528    let raw_hop = transparent_reference(types, position);
2529
2530    let mut position = position.clone();
2531    substitute_generics(&mut position, subst)?;
2532
2533    if let Some(hop) = raw_hop {
2534        let target_subst = edge_substitution(subst, hop.target, hop.reference)?;
2535        let hop = EffectiveHop {
2536            target: hop.target,
2537            target_subst,
2538            nullable: hop.nullable,
2539        };
2540        return Ok(Some((position, Some(hop))));
2541    }
2542
2543    let hop = match transparent_reference(types, &position) {
2544        Some(hop) => Some(EffectiveHop {
2545            target: hop.target,
2546            target_subst: edge_substitution(identity, hop.target, hop.reference)?,
2547            nullable: hop.nullable,
2548        }),
2549        None => None,
2550    };
2551    Ok(Some((position, hop)))
2552}
2553
2554/// Resolves the full substitution an in-cycle hop applies to its target: one
2555/// entry per *declared* parameter of the target, filled exactly the way
2556/// ordinary reference rendering fills them via
2557/// [`resolved_reference_generics`] - the explicit argument if present,
2558/// otherwise the parameter's declared default, otherwise `unknown` - so the
2559/// collapse and the renderer can never disagree about omitted parameters.
2560/// Each resolved value is then rewritten from the source node's scope into
2561/// the root's via `source_subst` (extended with the target's earlier
2562/// parameters, which declared defaults may reference).
2563fn edge_substitution(
2564    source_subst: &GenericSubst,
2565    target: &NamedDataType,
2566    reference: &NamedReference,
2567) -> Result<GenericSubst, Cow<'static, str>> {
2568    let (resolved, _, _) = resolved_reference_generics(target, reference, &[]).ok_or(
2569        Cow::Borrowed("the cycle contains a reference whose generic arguments cannot be resolved"),
2570    )?;
2571
2572    let mut scope = source_subst.clone();
2573    let mut target_subst = GenericSubst::with_capacity(resolved.len());
2574    for (generic, mut value) in target.generics.iter().zip(resolved) {
2575        substitute_generics(&mut value, &scope)?;
2576        scope.insert(generic.name.clone(), value.clone());
2577        target_subst.insert(generic.name.clone(), value);
2578    }
2579    Ok(target_subst)
2580}
2581
2582/// Whether a substitution maps every parameter to itself, i.e. applying it
2583/// is a no-op. The root's own variants merge under the identity, and only
2584/// non-identity substitutions can invalidate payloads the walk can't see
2585/// into (unknown opaque references).
2586fn subst_is_identity(subst: &GenericSubst) -> bool {
2587    subst
2588        .iter()
2589        .all(|(name, dt)| matches!(dt, DataType::Generic(g) if g.name() == name))
2590}
2591
2592/// Rewrites every generic parameter reference in `dt` (part of a cycle
2593/// member's variant being merged into the exported root type) to the
2594/// datatype the member is instantiated with, per `subst`. Errors abort the
2595/// collapse and fail the export - by this point the cycle is known to
2596/// render as illegal TypeScript (TS2456), so an honest error beats silently
2597/// emitting either that or a dangling parameter name.
2598fn substitute_generics(dt: &mut DataType, subst: &GenericSubst) -> Result<(), Cow<'static, str>> {
2599    match dt {
2600        DataType::Generic(g) => match subst.get(g.name()) {
2601            Some(replacement) => {
2602                *dt = replacement.clone();
2603                Ok(())
2604            }
2605            None => Err(Cow::Owned(format!(
2606                "the cycle references generic parameter `{}` with no instantiation for it",
2607                g.name()
2608            ))),
2609        },
2610        DataType::Primitive(_) => Ok(()),
2611        DataType::List(list) => substitute_generics(&mut list.ty, subst),
2612        DataType::Map(map) => {
2613            substitute_generics(map.key_ty_mut(), subst)?;
2614            substitute_generics(map.value_ty_mut(), subst)
2615        }
2616        DataType::Struct(s) => substitute_fields_generics(&mut s.fields, subst),
2617        DataType::Enum(e) => e
2618            .variants
2619            .iter_mut()
2620            .try_for_each(|(_, variant)| substitute_fields_generics(&mut variant.fields, subst)),
2621        DataType::Tuple(t) => t
2622            .elements
2623            .iter_mut()
2624            .try_for_each(|ty| substitute_generics(ty, subst)),
2625        DataType::Nullable(inner) => substitute_generics(inner, subst),
2626        DataType::Intersection(parts) => parts
2627            .iter_mut()
2628            .try_for_each(|ty| substitute_generics(ty, subst)),
2629        DataType::Reference(Reference::Named(r)) => match &mut r.inner {
2630            NamedReferenceType::Reference { generics, .. } => generics
2631                .iter_mut()
2632                .try_for_each(|(_, dt)| substitute_generics(dt, subst)),
2633            NamedReferenceType::Inline { dt, .. } => substitute_generics(dt, subst),
2634            NamedReferenceType::Recursive(_) => Ok(()),
2635        },
2636        DataType::Reference(Reference::Opaque(opaque_ref)) => {
2637            if let Some(branded) = opaque_ref.downcast_ref::<Branded>() {
2638                // A branded payload can embed generic parameters
2639                // (`branded!(struct Id<T>(T))` renders as `T & { ... }`),
2640                // so it has to be rewritten like any other datatype.
2641                let mut ty = branded.ty().clone();
2642                let brand = branded.brand().clone();
2643                substitute_generics(&mut ty, subst)?;
2644                *dt = DataType::Reference(Reference::opaque(Branded::new(brand, ty)));
2645                Ok(())
2646            } else if subst_is_identity(subst) {
2647                // Identity substitutions are no-ops: nothing is being
2648                // rewritten, so no payload can be invalidated.
2649                Ok(())
2650            } else if let Some(def) = opaque_ref.downcast_ref::<opaque::Define>() {
2651                // `define(...)` is raw TypeScript text: it can *name* the
2652                // member's generic parameters but cannot be structurally
2653                // rewritten (and textually rewriting raw TS would be
2654                // fragile). Allow it only when a conservative
2655                // word-boundary scan shows it mentions no parameter this
2656                // substitution actually rewrites.
2657                match subst.iter().find(|(name, replacement)| {
2658                    !matches!(replacement, DataType::Generic(g) if g.name() == *name)
2659                        && raw_ts_mentions_identifier(&def.0, name)
2660                }) {
2661                    Some((name, _)) => Err(Cow::Owned(format!(
2662                        "the cycle contains raw TypeScript (`define(...)`) that mentions generic parameter `{name}`, which cannot be rewritten"
2663                    ))),
2664                    None => Ok(()),
2665                }
2666            } else if opaque_ref.downcast_ref::<opaque::Any>().is_some()
2667                || opaque_ref.downcast_ref::<opaque::Unknown>().is_some()
2668                || opaque_ref.downcast_ref::<opaque::Never>().is_some()
2669                || opaque_ref.downcast_ref::<opaque::Number>().is_some()
2670                || opaque_ref.downcast_ref::<opaque::BigInt>().is_some()
2671            {
2672                // This crate's remaining opaque payloads can't embed a
2673                // datatype or mention a parameter at all.
2674                Ok(())
2675            } else {
2676                // An opaque reference from elsewhere might embed generic
2677                // parameters this walk can't see, let alone rewrite.
2678                Err(Cow::Owned(format!(
2679                    "the cycle contains an opaque reference (`{}`) whose payload cannot be checked for generic parameters",
2680                    opaque_ref.type_name()
2681                )))
2682            }
2683        }
2684    }
2685}
2686
2687/// Conservative check for whether raw TypeScript text (a `define(...)`
2688/// payload) mentions `name` as a standalone identifier - a word-boundary
2689/// match, so the parameter `U` is found in `ReadonlyArray<U>` but not in
2690/// `Unit`. Any hit means the raw text may depend on a generic parameter a
2691/// cycle merge would rewrite, which is impossible for raw text, so the
2692/// caller must reject the merge. Non-ASCII bytes are treated as identifier
2693/// characters, erring toward a match (and therefore rejection) around
2694/// exotic identifiers.
2695fn raw_ts_mentions_identifier(raw: &str, name: &str) -> bool {
2696    fn is_ident_byte(b: u8) -> bool {
2697        b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()
2698    }
2699
2700    if name.is_empty() {
2701        return false;
2702    }
2703    let bytes = raw.as_bytes();
2704    let mut search_start = 0;
2705    while let Some(pos) = raw[search_start..].find(name) {
2706        let start = search_start + pos;
2707        let end = start + name.len();
2708        if (start == 0 || !is_ident_byte(bytes[start - 1]))
2709            && (end == raw.len() || !is_ident_byte(bytes[end]))
2710        {
2711            return true;
2712        }
2713        // A later occurrence overlapping this one starts inside `name`, so
2714        // its preceding character is part of `name` - an identifier - and
2715        // would fail the boundary check anyway; skipping past `end` is safe
2716        // and keeps the cursor on a UTF-8 character boundary.
2717        search_start = end;
2718    }
2719    false
2720}
2721
2722/// Structural size of a datatype, used as a divergence guard for cycle
2723/// instantiations (see [`collapse_untagged_alias_cycle`] phase 3): a type
2724/// argument that grows on every trip around a cycle grows in weight, so
2725/// capping the weight bounds the walk even for exponential growth.
2726fn datatype_weight(dt: &DataType) -> usize {
2727    fn fields_weight(fields: &Fields) -> usize {
2728        match fields {
2729            Fields::Unit => 1,
2730            Fields::Unnamed(unnamed) => unnamed
2731                .fields
2732                .iter()
2733                .filter_map(|field| field.ty.as_ref())
2734                .map(datatype_weight)
2735                .sum(),
2736            Fields::Named(named) => named
2737                .fields
2738                .iter()
2739                .filter_map(|(_, field)| field.ty.as_ref())
2740                .map(datatype_weight)
2741                .sum(),
2742        }
2743    }
2744
2745    1_usize.saturating_add(match dt {
2746        DataType::Primitive(_) | DataType::Generic(_) => 0,
2747        DataType::List(list) => datatype_weight(&list.ty),
2748        DataType::Map(map) => {
2749            datatype_weight(map.key_ty()).saturating_add(datatype_weight(map.value_ty()))
2750        }
2751        DataType::Struct(s) => fields_weight(&s.fields),
2752        DataType::Enum(e) => e
2753            .variants
2754            .iter()
2755            .map(|(_, variant)| fields_weight(&variant.fields))
2756            .sum(),
2757        DataType::Tuple(t) => t.elements.iter().map(datatype_weight).sum(),
2758        DataType::Nullable(inner) => datatype_weight(inner),
2759        DataType::Intersection(parts) => parts.iter().map(datatype_weight).sum(),
2760        DataType::Reference(Reference::Named(r)) => match &r.inner {
2761            NamedReferenceType::Reference { generics, .. } => {
2762                generics.iter().map(|(_, dt)| datatype_weight(dt)).sum()
2763            }
2764            NamedReferenceType::Inline { dt, .. } => datatype_weight(dt),
2765            NamedReferenceType::Recursive(_) => 0,
2766        },
2767        DataType::Reference(Reference::Opaque(opaque_ref)) => opaque_ref
2768            .downcast_ref::<Branded>()
2769            .map(|branded| datatype_weight(branded.ty()))
2770            .unwrap_or(0),
2771    })
2772}
2773
2774fn substitute_fields_generics(
2775    fields: &mut Fields,
2776    subst: &GenericSubst,
2777) -> Result<(), Cow<'static, str>> {
2778    match fields {
2779        Fields::Unit => Ok(()),
2780        Fields::Unnamed(unnamed) => unnamed
2781            .fields
2782            .iter_mut()
2783            .filter_map(|field| field.ty.as_mut())
2784            .try_for_each(|ty| substitute_generics(ty, subst)),
2785        Fields::Named(named) => named
2786            .fields
2787            .iter_mut()
2788            .filter_map(|(_, field)| field.ty.as_mut())
2789            .try_for_each(|ty| substitute_generics(ty, subst)),
2790    }
2791}
2792
2793/// Detects whether `root` is part of an alias-transparent reference cycle -
2794/// i.e. whether naively rendering its variants would produce a TypeScript
2795/// alias that circularly references itself, directly or through other
2796/// untagged enums or passthrough exports (`type Rec = Rec | ...`, or
2797/// `type RecA = RecB | ...; type RecB = RecA | ...`).
2798///
2799/// Every transparent hop serializes as exactly its inner value, so the
2800/// wire-level value set of an enum in such a cycle is precisely the union
2801/// of the *non-cyclic* branches reachable from anywhere in the cycle (plus
2802/// `null` if any cyclic branch passes through an `Option`): any finite
2803/// value must eventually bottom out at one of them, since the recursive
2804/// branches never add wire structure of their own. Returns the merged,
2805/// cycle-free variant list to render in `root`'s place, or `Ok(None)` if
2806/// `root` isn't part of such a cycle - in which case its rendering must be
2807/// left completely untouched.
2808///
2809/// Returns an error (the human-readable reason) when a cycle exists but
2810/// cannot be collapsed. The naive rendering of such a cycle is illegal
2811/// TypeScript (TS2456), so failing the export loudly beats emitting it.
2812///
2813/// Regression test for https://github.com/specta-rs/specta/pull/517#discussion_r3584346217:
2814/// exporting `#[serde(untagged)] enum Rec { A(Box<Rec>), B(Map) }` used to
2815/// emit `export type Rec = Rec | Map;`, which `tsc` rejects.
2816fn collapse_untagged_alias_cycle(
2817    types: &Types,
2818    root: &NamedDataType,
2819) -> Result<Option<Enum>, Cow<'static, str>> {
2820    // Only enum exports assemble a union that can be collapsed. Passthrough
2821    // exports inside a cycle (see `transparent_export_hop`) keep their bare
2822    // alias body, which becomes legal once every enum in the cycle is
2823    // collapsed and the chain of aliases stops looping.
2824    if node_enum(root).is_none() {
2825        return Ok(None);
2826    }
2827    let root_id = alias_id(root);
2828
2829    let identity: GenericSubst = root
2830        .generics
2831        .iter()
2832        .map(|g| (g.name.clone(), DataType::Generic(g.reference())))
2833        .collect();
2834
2835    // Phase 1: discover the *effective* alias-transparent reference graph
2836    // reachable from `root`. Nodes are explored once per (node,
2837    // instantiation) pair (same dedup and divergence guards as the merge
2838    // walk), and hops are derived from each node's substituted form, so an
2839    // edge carried entirely by a generic argument - `Gen<Root>` where
2840    // `Gen<T>`'s only transparent branch is the bare parameter `T` - is
2841    // discovered exactly like a raw reference edge. TypeScript's alias
2842    // cycle check is declaration-level, so edges are recorded per node
2843    // (not per instantiation) for the cycle test in phase 2.
2844    //
2845    // A substitution failure here (e.g. an opaque payload the walk refuses
2846    // to touch) must not fail an export that may not be cyclic at all: the
2847    // raw hop's target is still recorded (its identity doesn't depend on
2848    // the substitution) and explored under its own identity, which keeps
2849    // raw reachability complete. If such a node does end up in a cycle,
2850    // the merge walk hits the same substitution error and fails honestly.
2851    let mut nodes: HashMap<AliasId, AliasNode<'_>> = HashMap::new();
2852    nodes.insert(
2853        root_id.clone(),
2854        AliasNode {
2855            ndt: root,
2856            substs: vec![identity.clone()],
2857            edges: Vec::new(),
2858        },
2859    );
2860
2861    let mut work: Vec<(AliasId, GenericSubst)> = vec![(root_id.clone(), identity.clone())];
2862    let mut cursor = 0;
2863    while cursor < work.len() {
2864        let (id, subst) = work[cursor].clone();
2865        cursor += 1;
2866
2867        let ndt = nodes[&id].ndt;
2868        let mut hops: Vec<(&NamedDataType, Option<GenericSubst>)> = Vec::new();
2869        match node_enum(ndt) {
2870            Some(enm) => {
2871                for (_, variant) in &enm.variants {
2872                    if variant.skip {
2873                        continue;
2874                    }
2875                    match effective_variant_hop(types, variant, &subst, &identity) {
2876                        Ok((_, Some(hop))) => hops.push((hop.target, Some(hop.target_subst))),
2877                        Ok((_, None)) => {}
2878                        Err(_) => {
2879                            if let Some(hop) = transparent_variant_hop(types, variant) {
2880                                hops.push((hop.target, None));
2881                            }
2882                        }
2883                    }
2884                }
2885            }
2886            None => match effective_export_hop(types, ndt, &subst, &identity) {
2887                Ok(Some((_, Some(hop)))) => hops.push((hop.target, Some(hop.target_subst))),
2888                Ok(_) => {}
2889                Err(_) => {
2890                    if let Some(hop) = transparent_export_hop(types, ndt) {
2891                        hops.push((hop.target, None));
2892                    }
2893                }
2894            },
2895        }
2896
2897        for (target, target_subst) in hops {
2898            let target_id = alias_id(target);
2899            if let Entry::Vacant(entry) = nodes.entry(target_id.clone()) {
2900                entry.insert(AliasNode {
2901                    ndt: target,
2902                    substs: Vec::new(),
2903                    edges: Vec::new(),
2904                });
2905            }
2906
2907            let source = nodes
2908                .get_mut(&id)
2909                .expect("node was inserted before being visited");
2910            if !source.edges.contains(&target_id) {
2911                source.edges.push(target_id.clone());
2912            }
2913
2914            // Fall back to the target's own identity when the hop's
2915            // instantiation couldn't be resolved, so its raw hops are
2916            // still explored.
2917            let target_subst = match target_subst {
2918                Some(target_subst) => target_subst,
2919                None => target
2920                    .generics
2921                    .iter()
2922                    .map(|g| (g.name.clone(), DataType::Generic(g.reference())))
2923                    .collect(),
2924            };
2925            enqueue_cycle_instantiation(&mut nodes, &mut work, target_id, target_subst)?;
2926        }
2927    }
2928
2929    // Phase 2: `root`'s strongly-connected component. Every discovered node
2930    // is reachable from `root`, so the members of `root`'s cycle are exactly
2931    // the nodes that can reach `root` back - found by walking the reversed
2932    // edges from `root`. `root` itself shows up iff some cycle through it
2933    // exists at all.
2934    let mut reverse: HashMap<&AliasId, Vec<&AliasId>> = HashMap::new();
2935    for (id, node) in &nodes {
2936        for target in &node.edges {
2937            reverse.entry(target).or_default().push(id);
2938        }
2939    }
2940    let mut in_cycle: HashSet<AliasId> = HashSet::new();
2941    let mut frontier = vec![&root_id];
2942    while let Some(id) = frontier.pop() {
2943        for &source in reverse.get(id).into_iter().flatten() {
2944            if in_cycle.insert(source.clone()) {
2945                frontier.push(source);
2946            }
2947        }
2948    }
2949    if !in_cycle.contains(&root_id) {
2950        return Ok(None);
2951    }
2952    drop(frontier);
2953    drop(reverse);
2954
2955    // Phase 3: propagate generic instantiations around the cycle to a fixed
2956    // point, starting from the root's identity, merging each (member,
2957    // instantiation) pair's non-back-edge branches as it is processed. A
2958    // member can be reached with several distinct instantiations (`Gen<T>`
2959    // as the root *and* as `Gen<String>` through the cycle); each one
2960    // contributes a copy of its branches.
2961    //
2962    // Back edges are classified on each branch's *effective* hop (see
2963    // `effective_variant_hop`): the raw hop when the branch is one, or the
2964    // hop its substituted form reveals (`B(T)` with `T = Root`, or
2965    // `T = Gen<String>` for a cycle member `Gen`). A back edge is dropped -
2966    // it adds no wire structure of its own, except a `Nullable` hop whose
2967    // `None` case is a real `null` wire value to keep - and its
2968    // instantiation is fed through this same work list, so its terminals
2969    // still merge. Discovery errors were tolerated in phase 1; here the
2970    // cycle is confirmed, so substitution failures fail the export
2971    // honestly.
2972    //
2973    // Termination: only genuinely new (node, substitution) pairs enter the
2974    // work list (see `enqueue_cycle_instantiation`), so the walk stops
2975    // exactly when the instantiation set stops growing - a finite set of
2976    // any size converges. Branches are merged in work-list order (`root`'s
2977    // own branches under the identity first), so the output is
2978    // deterministic; `push_union` dedupes identical renderings.
2979    for node in nodes.values_mut() {
2980        node.substs.clear();
2981    }
2982    let mut work: Vec<(AliasId, GenericSubst)> = vec![(root_id.clone(), identity.clone())];
2983    nodes
2984        .get_mut(&root_id)
2985        .expect("root node always exists")
2986        .substs
2987        .push(identity.clone());
2988
2989    let mut variants = Vec::new();
2990    let mut needs_null = false;
2991    let mut cursor = 0;
2992    while cursor < work.len() {
2993        let (id, subst) = work[cursor].clone();
2994        cursor += 1;
2995
2996        let ndt = nodes[&id].ndt;
2997        let Some(enm) = node_enum(ndt) else {
2998            // Passthrough member: its effective body is usually a back edge
2999            // into the cycle (contributing nothing but a possible `null`
3000            // remnant), but under some instantiations it can be a hop out
3001            // of the cycle or no hop at all (`W<T>` reached at
3002            // `T = string`), in which case the substituted body is a
3003            // terminal to keep.
3004            let Some((position, hop)) = effective_export_hop(types, ndt, &subst, &identity)? else {
3005                continue;
3006            };
3007            match hop {
3008                Some(hop) if in_cycle.contains(&alias_id(hop.target)) => {
3009                    needs_null |= hop.nullable;
3010                    enqueue_cycle_instantiation(
3011                        &mut nodes,
3012                        &mut work,
3013                        alias_id(hop.target),
3014                        hop.target_subst,
3015                    )?;
3016                }
3017                _ => variants.push((
3018                    Cow::Borrowed("passthrough"),
3019                    Variant::unnamed().field(Field::new(position)).build(),
3020                )),
3021            }
3022            continue;
3023        };
3024
3025        for (name, variant) in &enm.variants {
3026            if variant.skip {
3027                continue;
3028            }
3029
3030            let (variant, hop) = effective_variant_hop(types, variant, &subst, &identity)?;
3031            if let Some(hop) = hop
3032                && in_cycle.contains(&alias_id(hop.target))
3033            {
3034                needs_null |= hop.nullable;
3035                enqueue_cycle_instantiation(
3036                    &mut nodes,
3037                    &mut work,
3038                    alias_id(hop.target),
3039                    hop.target_subst,
3040                )?;
3041                continue;
3042            }
3043
3044            variants.push((name.clone(), variant));
3045        }
3046    }
3047
3048    if needs_null {
3049        // The same `null`-rendering empty-tuple shape `specta-serde` uses
3050        // for untagged unit variants.
3051        variants.push((
3052            Cow::Borrowed("null"),
3053            Variant::unnamed()
3054                .field(Field::new(DataType::Tuple(Tuple::new(vec![]))))
3055                .build(),
3056        ));
3057    }
3058
3059    let mut collapsed = Enum::default();
3060    collapsed.variants = variants;
3061    Ok(Some(collapsed))
3062}
3063
3064/// Returns the [`DataType`] to render as `ndt`'s own top-level export body,
3065/// collapsing an untagged-enum alias cycle into valid TypeScript if `ndt` is
3066/// part of one (see [`collapse_untagged_alias_cycle`]). Borrows `ndt.ty`
3067/// unchanged in the (overwhelmingly common) non-cyclic case.
3068fn resolve_named_export_body<'a>(
3069    types: &Types,
3070    ndt: &'a NamedDataType,
3071) -> Result<Cow<'a, DataType>, Error> {
3072    let ty = ndt.ty.as_ref().expect("named datatype must have a body");
3073    match collapse_untagged_alias_cycle(types, ndt) {
3074        Ok(Some(collapsed)) => Ok(Cow::Owned(DataType::Enum(collapsed))),
3075        Ok(None) => Ok(Cow::Borrowed(ty)),
3076        Err(reason) => Err(Error::unrepresentable_alias_cycle(
3077            rust_type_path(ndt).into_owned(),
3078            reason,
3079        )
3080        .with_named_datatype(ndt)),
3081    }
3082}
3083
3084fn enum_dt(
3085    s: &mut String,
3086    exporter: &Exporter,
3087    types: &Types,
3088    e: &Enum,
3089    location: Vec<Cow<'static, str>>,
3090    prefix: &str,
3091    generics: &[(GenericReference, DataType)],
3092) -> Result<(), Error> {
3093    let is_finite_number = e.attributes.get_named_as("specta:finite_number") == Some(&true);
3094
3095    if e.attributes.get_named_as(FIELD_ALIAS_UNION_MARKER) == Some(&true) {
3096        return alias_field_union_dt(s, exporter, types, e, location, prefix, generics);
3097    }
3098
3099    if e.variants.is_empty() {
3100        s.push_str(NEVER);
3101        return Ok(());
3102    }
3103
3104    let filtered_variants = active_variants(e);
3105
3106    let discriminator = analyze_discriminator(&filtered_variants);
3107    let fallback_override = fallback_discriminator_override(discriminator.as_ref());
3108
3109    let mut rendered_variants = Vec::with_capacity(filtered_variants.len());
3110    for (idx, (variant_name, variant)) in filtered_variants.iter().enumerate() {
3111        let variant_override = fallback_override
3112            .as_ref()
3113            .and_then(|(fallback_idx, key, ty)| {
3114                (*fallback_idx == idx).then_some(VariantTypeOverride {
3115                    key,
3116                    ty: ty.as_str(),
3117                })
3118            });
3119
3120        let mut variant_location = location.clone();
3121        variant_location.push(variant_name.clone());
3122        let ts_values = enum_variant_datatype(
3123            exporter,
3124            None,
3125            types,
3126            variant_name.clone(),
3127            variant,
3128            variant_location,
3129            prefix,
3130            generics,
3131            variant_override,
3132        )?;
3133
3134        rendered_variants.push(EnumVariantOutput {
3135            value: ts_values.unwrap_or_else(|| NEVER.to_string()),
3136            strict_keys: untagged_strict_keys(variant),
3137        });
3138    }
3139
3140    // Rendering first preserves errors for unremapped BigInt-style variants.
3141    if is_finite_number {
3142        s.push_str("number");
3143        return Ok(());
3144    }
3145
3146    if discriminator.is_none() && !has_anonymous_variant(&filtered_variants) {
3147        strictify_enum_variants(&mut rendered_variants);
3148    }
3149
3150    let variants = filtered_variants
3151        .into_iter()
3152        .zip(rendered_variants)
3153        .map(|((_, variant), rendered)| {
3154            inner_comments(
3155                variant.deprecated.as_ref(),
3156                &variant.docs,
3157                rendered.value,
3158                true,
3159                prefix,
3160            )
3161        })
3162        .collect::<Vec<_>>();
3163
3164    push_union(s, variants);
3165
3166    Ok(())
3167}
3168
3169/// Renders serde field aliases as a deferred mapped XOR instead of an
3170/// intersection of unions. The latter distributes into one union member for
3171/// every combination of aliased fields, which quickly reaches TS2590.
3172///
3173/// The mapped type retains the original semantics for required fields: each
3174/// must use exactly one accepted spelling. Fully optional alias groups render
3175/// as their plain object intersection instead. TypeScript cannot retain their
3176/// duplicate-key rejection without forcing consumers to expand an exponential
3177/// union, while serde's optional/omitted behavior remains represented.
3178fn alias_field_union_dt(
3179    s: &mut String,
3180    exporter: &Exporter,
3181    types: &Types,
3182    e: &Enum,
3183    location: Vec<Cow<'static, str>>,
3184    prefix: &str,
3185    generics: &[(GenericReference, DataType)],
3186) -> Result<(), Error> {
3187    let mut fields = Vec::with_capacity(e.variants.len());
3188    let mut all_optional = true;
3189
3190    for (name, variant) in active_variants(e) {
3191        let mut variant = (*variant).clone();
3192        if let Fields::Unnamed(unnamed) = &mut variant.fields
3193            && let Some(DataType::Struct(strct)) = unnamed
3194                .fields
3195                .first_mut()
3196                .and_then(|field| field.ty.as_mut())
3197            && let Fields::Named(named) = &mut strct.fields
3198        {
3199            named
3200                .fields
3201                .retain(|(_, field)| !field.attributes.contains_key(FIELD_ALIAS_EXCLUSION_MARKER));
3202            all_optional &= named.fields.iter().all(|(_, field)| field.optional);
3203        } else {
3204            all_optional = false;
3205        }
3206
3207        let mut variant_location = location.clone();
3208        variant_location.push(name.clone());
3209        let field = enum_variant_datatype(
3210            exporter,
3211            None,
3212            types,
3213            name.clone(),
3214            &variant,
3215            variant_location,
3216            prefix,
3217            generics,
3218            None,
3219        )?
3220        .unwrap_or_else(|| NEVER.to_string());
3221        fields.push(field);
3222    }
3223
3224    let source = fields.join(" & ");
3225    if all_optional {
3226        s.push_str(&source);
3227        return Ok(());
3228    }
3229
3230    s.push_str("((");
3231    s.push_str(&source);
3232    s.push_str(") extends infer T extends object ? { [K in keyof T]: { [P in keyof T as P extends K ? P : never]: T[P] } & { [P in keyof T as P extends K ? never : P]?: never } }[keyof T] & object : never)");
3233
3234    Ok(())
3235}
3236
3237fn tuple_dt(
3238    s: &mut String,
3239    exporter: &Exporter,
3240    types: &Types,
3241    t: &Tuple,
3242    location: Vec<Cow<'static, str>>,
3243    generics: &[(GenericReference, DataType)],
3244) -> Result<(), Error> {
3245    match t.elements.as_slice() {
3246        [] => s.push_str(NULL),
3247        elements => {
3248            s.push('[');
3249            for (idx, dt) in elements.iter().enumerate() {
3250                if idx != 0 {
3251                    s.push_str(", ");
3252                }
3253                let mut element_location = location.clone();
3254                element_location.push(idx.to_string().into());
3255                datatype(
3256                    s,
3257                    exporter,
3258                    None,
3259                    types,
3260                    dt,
3261                    element_location,
3262                    None,
3263                    "",
3264                    generics,
3265                )?;
3266            }
3267            s.push(']');
3268        }
3269    }
3270
3271    Ok(())
3272}
3273
3274fn reference_dt(
3275    s: &mut String,
3276    exporter: &Exporter,
3277    format: Option<&dyn Format>,
3278    types: &Types,
3279    r: &Reference,
3280    location: Vec<Cow<'static, str>>,
3281    prefix: &str,
3282    generics: &[(GenericReference, DataType)],
3283) -> Result<(), Error> {
3284    match r {
3285        Reference::Named(r) => match &r.inner {
3286            NamedReferenceType::Reference { .. } => {
3287                reference_named_dt(s, exporter, types, r, location, generics)
3288            }
3289            NamedReferenceType::Inline { dt, .. } => {
3290                let inline_path = path_string(&location);
3291                inline_datatype(
3292                    s, exporter, format, types, dt, location, None, prefix, 0, generics,
3293                )
3294                .map_err(|err| err.with_inline_trace(types.get(r), inline_path))
3295            }
3296            NamedReferenceType::Recursive(cycle) => Err(Error::infinite_recursive_inline_type(
3297                path_string(&location),
3298                format!("{r:?}"),
3299                cycle.clone(),
3300            )),
3301        },
3302        Reference::Opaque(r) => reference_opaque_dt(s, exporter, format, types, r, location),
3303    }
3304}
3305
3306fn reference_opaque_dt(
3307    s: &mut String,
3308    exporter: &Exporter,
3309    format: Option<&dyn Format>,
3310    types: &Types,
3311    r: &OpaqueReference,
3312    location: Vec<Cow<'static, str>>,
3313) -> Result<(), Error> {
3314    if let Some(def) = r.downcast_ref::<opaque::Define>() {
3315        s.push_str(&def.0);
3316        return Ok(());
3317    }
3318
3319    if r.downcast_ref::<opaque::Any>().is_some() {
3320        s.push_str("any");
3321        return Ok(());
3322    }
3323
3324    if r.downcast_ref::<opaque::Unknown>().is_some() {
3325        s.push_str("unknown");
3326        return Ok(());
3327    }
3328
3329    if r.downcast_ref::<opaque::Never>().is_some() {
3330        s.push_str("never");
3331        return Ok(());
3332    }
3333
3334    if r.downcast_ref::<opaque::Number>().is_some() {
3335        s.push_str("number");
3336        return Ok(());
3337    }
3338
3339    if r.downcast_ref::<opaque::BigInt>().is_some() {
3340        s.push_str("bigint");
3341        return Ok(());
3342    }
3343
3344    if let Some(def) = r.downcast_ref::<Branded>() {
3345        if let Some(branded_type) = exporter
3346            .branded_type_impl
3347            .as_ref()
3348            .map(|builder| {
3349                (builder.0)(
3350                    BrandedTypeExporter {
3351                        exporter,
3352                        format,
3353                        types,
3354                    },
3355                    def,
3356                )
3357            })
3358            .transpose()?
3359        {
3360            s.push_str(branded_type.as_ref());
3361            return Ok(());
3362        }
3363
3364        match def.ty() {
3365            DataType::Reference(r) => {
3366                reference_dt(s, exporter, format, types, r, location.clone(), "", &[])?
3367            }
3368            ty => inline_datatype(
3369                s,
3370                exporter,
3371                format,
3372                types,
3373                ty,
3374                location.clone(),
3375                None,
3376                "",
3377                0,
3378                &[],
3379            )?,
3380        }
3381        s.push_str(r#" & { readonly __brand: ""#);
3382        s.push_str(&escape_typescript_string_literal(def.brand()));
3383        s.push_str("\" }");
3384        return Ok(());
3385    }
3386
3387    Err(Error::unsupported_opaque_reference(
3388        path_string(&location),
3389        r.clone(),
3390    ))
3391}
3392
3393fn reference_named_dt(
3394    s: &mut String,
3395    exporter: &Exporter,
3396    types: &Types,
3397    r: &NamedReference,
3398    location: Vec<Cow<'static, str>>,
3399    generics: &[(GenericReference, DataType)],
3400) -> Result<(), Error> {
3401    let path = path_string(&location);
3402    let ndt = types
3403        .get(r)
3404        .ok_or_else(|| Error::dangling_named_reference(path.clone(), format!("{r:?}")))?;
3405    // We check it's valid before tracking
3406    crate::references::track_nr(r);
3407
3408    let name = referenced_type_name(exporter, ndt);
3409
3410    let (rendered_generics, omit_generics, scoped_generics) =
3411        resolved_reference_generics(ndt, r, generics)
3412            .ok_or_else(|| Error::dangling_named_reference(path, format!("{r:?}")))?;
3413
3414    s.push_str(&name);
3415    if !omit_generics && !rendered_generics.is_empty() {
3416        s.push('<');
3417
3418        for (i, dt) in rendered_generics.iter().enumerate() {
3419            if i != 0 {
3420                s.push_str(", ");
3421            }
3422
3423            datatype(
3424                s,
3425                exporter,
3426                None,
3427                types,
3428                dt,
3429                vec![],
3430                None,
3431                "",
3432                &scoped_generics,
3433            )?;
3434        }
3435
3436        s.push('>');
3437    }
3438
3439    Ok(())
3440}