Skip to main content

vox_codegen/targets/typescript/
types.rs

1//! TypeScript type generation and collection.
2//!
3//! This module handles:
4//! - Collecting named types (structs and enums) from service definitions
5//! - Generating TypeScript type definitions (interfaces, type unions)
6//! - Converting Rust types to TypeScript type strings
7
8use std::collections::HashSet;
9
10use facet_core::{Def, ScalarType, Shape, StructKind, Type, UserType};
11use vox_types::{
12    EnumInfo, ServiceDescriptor, ShapeKind, StructInfo, VariantKind, classify_shape,
13    classify_variant, is_bytes,
14};
15
16fn transparent_named_alias(shape: &'static Shape) -> Option<(&'static str, &'static Shape)> {
17    if !shape.is_transparent() {
18        return None;
19    }
20    let name = extract_type_name(shape)?;
21    let inner = shape.inner?;
22    Some((name, inner))
23}
24
25fn enum_discriminator_field(variants: &[facet_core::Variant]) -> &'static str {
26    if variants
27        .iter()
28        .any(|variant| match classify_variant(variant) {
29            VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
30                fields.iter().any(|field| field.name == "tag")
31            }
32            VariantKind::Unit | VariantKind::Newtype { .. } => false,
33        })
34    {
35        "$tag"
36    } else {
37        "tag"
38    }
39}
40
41/// The declared name of a shape, or `None` for structurally anonymous types
42/// (tuples, arrays, slices) which have no name to give.
43///
44/// This used to test the *name* for a leading `(` or `[`, which leans on how an
45/// anonymous type's identifier happens to be synthesised. `Type`/`Def` say it
46/// outright.
47fn extract_type_name(shape: &'static Shape) -> Option<&'static str> {
48    let anonymous = matches!(shape.def, Def::Array(_) | Def::Slice(_))
49        || matches!(shape.ty, Type::User(UserType::Struct(s)) if s.kind == StructKind::Tuple);
50
51    if anonymous || shape.type_identifier.is_empty() {
52        return None;
53    }
54    Some(shape.type_identifier)
55}
56
57/// Generate TypeScript field access expression.
58/// Uses bracket notation for numeric field names (tuple fields), dot notation otherwise.
59pub fn ts_field_access(expr: &str, field_name: &str) -> String {
60    if field_name
61        .chars()
62        .next()
63        .is_some_and(|c| c.is_ascii_digit())
64    {
65        format!("{expr}[{field_name}]")
66    } else {
67        format!("{expr}.{field_name}")
68    }
69}
70
71/// Collect all named types (structs and enums with a name) from a service.
72/// Returns a vector of (name, Shape) pairs in dependency order.
73pub fn collect_named_types(service: &ServiceDescriptor) -> Vec<(String, &'static Shape)> {
74    let mut seen = HashSet::new();
75    let mut types = Vec::new();
76
77    fn visit(
78        shape: &'static Shape,
79        seen: &mut HashSet<String>,
80        types: &mut Vec<(String, &'static Shape)>,
81    ) {
82        if is_dynamic_value(shape) {
83            return;
84        }
85
86        if let Some((name, inner)) = transparent_named_alias(shape) {
87            if !seen.contains(name) {
88                seen.insert(name.to_string());
89                visit(inner, seen, types);
90                types.push((name.to_string(), shape));
91            }
92            return;
93        }
94
95        match classify_shape(shape) {
96            ShapeKind::Struct(StructInfo {
97                name: Some(name),
98                fields,
99                ..
100            }) if seen.insert(name.to_string()) => {
101                // Visit nested types first (dependencies before dependents)
102                for field in fields {
103                    visit(field.shape(), seen, types);
104                }
105                types.push((name.to_string(), shape));
106            }
107            ShapeKind::Enum(EnumInfo {
108                name: Some(name),
109                variants,
110            }) if seen.insert(name.to_string()) => {
111                // Visit nested types in variants
112                for variant in variants {
113                    match classify_variant(variant) {
114                        VariantKind::Newtype { inner } => visit(inner, seen, types),
115                        VariantKind::Struct { fields } | VariantKind::Tuple { fields } => {
116                            for field in fields {
117                                visit(field.shape(), seen, types);
118                            }
119                        }
120                        VariantKind::Unit => {}
121                    }
122                }
123                types.push((name.to_string(), shape));
124            }
125            ShapeKind::List { element } => visit(element, seen, types),
126            ShapeKind::Option { inner } => visit(inner, seen, types),
127            ShapeKind::Array { element, .. } => visit(element, seen, types),
128            ShapeKind::Map { key, value } => {
129                visit(key, seen, types);
130                visit(value, seen, types);
131            }
132            ShapeKind::Set { element } => visit(element, seen, types),
133            ShapeKind::Tuple { elements } => {
134                for param in elements {
135                    visit(param.shape, seen, types);
136                }
137            }
138            ShapeKind::TupleStruct { fields } => {
139                for field in fields {
140                    visit(field.shape(), seen, types);
141                }
142            }
143            ShapeKind::Tx { inner } | ShapeKind::Rx { inner } => visit(inner, seen, types),
144            ShapeKind::Pointer { pointee } => visit(pointee, seen, types),
145            ShapeKind::Result { ok, err } => {
146                visit(ok, seen, types);
147                visit(err, seen, types);
148            }
149            // Scalars, slices, opaque - no named types to collect
150            _ => {}
151        }
152    }
153
154    for method in service.methods {
155        for arg in method.args {
156            visit(arg.shape, &mut seen, &mut types);
157            if let Some(element) = arg.channel_element {
158                visit(element, &mut seen, &mut types);
159            }
160        }
161        visit(method.return_shape, &mut seen, &mut types);
162    }
163
164    types
165}
166
167/// Generate TypeScript type definitions for all named types.
168pub fn generate_named_types(named_types: &[(String, &'static Shape)]) -> String {
169    let mut out = String::new();
170
171    if named_types.is_empty() {
172        return out;
173    }
174
175    out.push_str("// Named type definitions\n");
176
177    for (name, shape) in named_types {
178        if let Some((_, inner)) = transparent_named_alias(shape) {
179            out.push_str(&format!(
180                "export type {name} = {};\n\n",
181                ts_type_base_named(inner)
182            ));
183            continue;
184        }
185
186        match classify_shape(shape) {
187            ShapeKind::Struct(StructInfo { fields, .. }) => {
188                out.push_str(&format!("export interface {} {{\n", name));
189                for field in fields {
190                    out.push_str(&format!(
191                        "  {}: {};\n",
192                        field.name,
193                        ts_type_base_named(field.shape())
194                    ));
195                }
196                out.push_str("}\n\n");
197            }
198            ShapeKind::Enum(EnumInfo { variants, .. }) => {
199                let discriminator = enum_discriminator_field(variants);
200                out.push_str(&format!("export type {} =\n", name));
201                for (i, variant) in variants.iter().enumerate() {
202                    let variant_type = match classify_variant(variant) {
203                        VariantKind::Unit => {
204                            format!("{{ {discriminator}: '{}' }}", variant.name)
205                        }
206                        VariantKind::Newtype { inner } => {
207                            format!(
208                                "{{ {discriminator}: '{}'; value: {} }}",
209                                variant.name,
210                                ts_type_base_named(inner)
211                            )
212                        }
213                        VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
214                            if fields.len() > 4 {
215                                let mut variant_type =
216                                    format!("{{\n    {discriminator}: '{}';\n", variant.name);
217                                for field in fields {
218                                    variant_type.push_str(&format!(
219                                        "    {}: {};\n",
220                                        field.name,
221                                        ts_type_base_named(field.shape())
222                                    ));
223                                }
224                                variant_type.push_str("  }");
225                                variant_type
226                            } else {
227                                let field_strs = fields
228                                    .iter()
229                                    .map(|f| {
230                                        format!("{}: {}", f.name, ts_type_base_named(f.shape()))
231                                    })
232                                    .collect::<Vec<_>>()
233                                    .join("; ");
234                                format!("{{ {discriminator}: '{}'; {} }}", variant.name, field_strs)
235                            }
236                        }
237                    };
238                    let prefix = if i == 0 { "  " } else { "  | " };
239                    let sep = if i < variants.len() - 1 { "" } else { ";" };
240                    out.push_str(&format!("{prefix}{variant_type}{sep}\n"));
241                }
242                out.push('\n');
243            }
244            _ => {}
245        }
246    }
247
248    out
249}
250
251/// Convert Shape to TypeScript type string, using named types when available.
252/// This handles container types recursively, using named types at every level.
253pub fn ts_type_base_named(shape: &'static Shape) -> String {
254    if is_dynamic_value(shape) {
255        return "Value".into();
256    }
257
258    if let Some((name, _)) = transparent_named_alias(shape) {
259        return name.to_string();
260    }
261
262    match classify_shape(shape) {
263        // Named types - use the name directly
264        ShapeKind::Struct(StructInfo {
265            name: Some(name), ..
266        }) => name.to_string(),
267        ShapeKind::Enum(EnumInfo {
268            name: Some(name), ..
269        }) => name.to_string(),
270
271        // Container types - recurse with ts_type_base_named
272        ShapeKind::List { element } => {
273            // Check for bytes first
274            if is_bytes(shape) {
275                return "Uint8Array".into();
276            }
277            // Wrap in parens if inner is an anonymous enum to avoid precedence issues
278            if matches!(
279                classify_shape(element),
280                ShapeKind::Enum(EnumInfo { name: None, .. })
281            ) {
282                format!("({})[]", ts_type_base_named(element))
283            } else {
284                format!("{}[]", ts_type_base_named(element))
285            }
286        }
287        ShapeKind::Option { inner } => format!("{} | null", ts_type_base_named(inner)),
288        ShapeKind::Array { element, len } => format!("[{}; {}]", ts_type_base_named(element), len),
289        ShapeKind::Map { key, value } => {
290            format!(
291                "Map<{}, {}>",
292                ts_type_base_named(key),
293                ts_type_base_named(value)
294            )
295        }
296        ShapeKind::Set { element } => format!("Set<{}>", ts_type_base_named(element)),
297        ShapeKind::Tuple { elements } => {
298            let inner = elements
299                .iter()
300                .map(|p| ts_type_base_named(p.shape))
301                .collect::<Vec<_>>()
302                .join(", ");
303            format!("[{inner}]")
304        }
305        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_base_named(inner)),
306        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_base_named(inner)),
307
308        // Anonymous structs - inline as object type
309        ShapeKind::Struct(StructInfo {
310            name: None, fields, ..
311        }) => {
312            let inner = fields
313                .iter()
314                .map(|f| format!("{}: {}", f.name, ts_type_base_named(f.shape())))
315                .collect::<Vec<_>>()
316                .join("; ");
317            format!("{{ {inner} }}")
318        }
319
320        // Anonymous enums - inline as union type
321        ShapeKind::Enum(EnumInfo {
322            name: None,
323            variants,
324        }) => {
325            let discriminator = enum_discriminator_field(variants);
326            variants
327                .iter()
328                .map(|v| match classify_variant(v) {
329                    VariantKind::Unit => format!("{{ {discriminator}: '{}' }}", v.name),
330                    VariantKind::Newtype { inner } => {
331                        format!(
332                            "{{ {discriminator}: '{}'; value: {} }}",
333                            v.name,
334                            ts_type_base_named(inner)
335                        )
336                    }
337                    VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
338                        let field_strs = fields
339                            .iter()
340                            .map(|f| format!("{}: {}", f.name, ts_type_base_named(f.shape())))
341                            .collect::<Vec<_>>()
342                            .join("; ");
343                        format!("{{ {discriminator}: '{}'; {} }}", v.name, field_strs)
344                    }
345                })
346                .collect::<Vec<_>>()
347                .join(" | ")
348        }
349
350        // Scalars and other types
351        ShapeKind::Scalar(scalar) => ts_scalar_type(scalar),
352        ShapeKind::Slice { element } => format!("{}[]", ts_type_base_named(element)),
353        ShapeKind::Pointer { pointee } => ts_type_base_named(pointee),
354        ShapeKind::Result { ok, err } => {
355            format!(
356                "{{ ok: true; value: {} }} | {{ ok: false; error: {} }}",
357                ts_type_base_named(ok),
358                ts_type_base_named(err)
359            )
360        }
361        ShapeKind::TupleStruct { fields } => {
362            let inner = fields
363                .iter()
364                .map(|f| ts_type_base_named(f.shape()))
365                .collect::<Vec<_>>()
366                .join(", ");
367            format!("[{inner}]")
368        }
369        ShapeKind::Opaque => "unknown".into(),
370    }
371}
372
373pub fn is_dynamic_value(shape: &'static Shape) -> bool {
374    matches!(shape.def, facet_core::Def::DynamicValue(_))
375}
376
377/// Convert ScalarType to TypeScript type string.
378pub fn ts_scalar_type(scalar: ScalarType) -> String {
379    match scalar {
380        ScalarType::Bool => "boolean".into(),
381        ScalarType::U8
382        | ScalarType::U16
383        | ScalarType::U32
384        | ScalarType::I8
385        | ScalarType::I16
386        | ScalarType::I32
387        | ScalarType::F32
388        | ScalarType::F64 => "number".into(),
389        ScalarType::U64
390        | ScalarType::U128
391        | ScalarType::I64
392        | ScalarType::I128
393        | ScalarType::USize
394        | ScalarType::ISize => "bigint".into(),
395        ScalarType::Char | ScalarType::Str | ScalarType::String | ScalarType::CowStr => {
396            "string".into()
397        }
398        ScalarType::Unit => "void".into(),
399        _ => "unknown".into(),
400    }
401}
402
403/// Convert Shape to TypeScript type string for client arguments.
404/// Schema is from server's perspective - no inversion needed.
405/// Client passes the same types that server receives.
406pub fn ts_type_client_arg(shape: &'static Shape) -> String {
407    match classify_shape(shape) {
408        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_client_arg(inner)),
409        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_client_arg(inner)),
410        _ => ts_type_base_named(shape),
411    }
412}
413
414/// Convert Shape to TypeScript type string for client returns.
415/// Schema is from server's perspective - no inversion needed.
416pub fn ts_type_client_return(shape: &'static Shape) -> String {
417    crate::targets::swift::types::assert_no_channels_in_return_shape(shape);
418    ts_type_base_named(shape)
419}
420
421/// Convert Shape to TypeScript type string for server/handler arguments.
422/// Schema is from server's perspective - no inversion needed.
423/// Rx means server receives, Tx means server sends.
424pub fn ts_type_server_arg(shape: &'static Shape) -> String {
425    match classify_shape(shape) {
426        ShapeKind::Tx { inner } => format!("Tx<{}>", ts_type_server_arg(inner)),
427        ShapeKind::Rx { inner } => format!("Rx<{}>", ts_type_server_arg(inner)),
428        _ => ts_type_base_named(shape),
429    }
430}
431
432/// Schema is from server's perspective - no inversion needed.
433pub fn ts_type_server_return(shape: &'static Shape) -> String {
434    crate::targets::swift::types::assert_no_channels_in_return_shape(shape);
435    ts_type_base_named(shape)
436}
437
438/// TypeScript type for user-facing type definitions.
439/// Uses named types when available.
440pub fn ts_type(shape: &'static Shape) -> String {
441    ts_type_base_named(shape)
442}
443
444/// Check if a type can be fully encoded/decoded.
445/// Channel types (Tx/Rx) are supported - they encode as channel IDs.
446pub fn is_fully_supported(shape: &'static Shape) -> bool {
447    if is_dynamic_value(shape) {
448        return true;
449    }
450
451    match classify_shape(shape) {
452        // Channel types are supported - they encode/decode as channel IDs
453        ShapeKind::Tx { inner } | ShapeKind::Rx { inner } => is_fully_supported(inner),
454        ShapeKind::List { element }
455        | ShapeKind::Option { inner: element }
456        | ShapeKind::Set { element }
457        | ShapeKind::Array { element, .. }
458        | ShapeKind::Slice { element } => is_fully_supported(element),
459        ShapeKind::Map { key, value } => is_fully_supported(key) && is_fully_supported(value),
460        ShapeKind::Tuple { elements } => elements.iter().all(|p| is_fully_supported(p.shape)),
461        ShapeKind::TupleStruct { fields } => fields.iter().all(|f| is_fully_supported(f.shape())),
462        ShapeKind::Struct(StructInfo { fields, .. }) => {
463            fields.iter().all(|f| is_fully_supported(f.shape()))
464        }
465        ShapeKind::Enum(EnumInfo { variants, .. }) => {
466            variants.iter().all(|v| match classify_variant(v) {
467                VariantKind::Unit => true,
468                VariantKind::Newtype { inner } => is_fully_supported(inner),
469                VariantKind::Tuple { fields } | VariantKind::Struct { fields } => {
470                    fields.iter().all(|f| is_fully_supported(f.shape()))
471                }
472            })
473        }
474        ShapeKind::Pointer { pointee } => is_fully_supported(pointee),
475        ShapeKind::Scalar(_) => true,
476        ShapeKind::Result { ok, err } => is_fully_supported(ok) && is_fully_supported(err),
477        ShapeKind::Opaque => false,
478    }
479}