Skip to main content

specta_typescript/
error.rs

1use std::{borrow::Cow, error, fmt, io, panic::Location, path::PathBuf};
2
3use specta::datatype::{NamedDataType, OpaqueReference, RecursiveInlineType};
4
5use crate::Layout;
6
7/// The error type for the TypeScript exporter.
8///
9/// ## BigInt Forbidden
10///
11/// Specta Typescript intentionally forbids exporting BigInt-style Rust integer types.
12/// This includes [usize], [isize], [i64], [u64], [u128], [i128] and [f128].
13///
14/// This guard exists because `JSON.parse` will truncate large integers to fit into a JavaScript `number` type so we explicitly forbid exporting them.
15///
16/// We take the stance that correctness matters more than developer experience as people using Rust generally strive for correctness.
17///
18/// If you encounter this error, there are a few common migration paths (in order of preference):
19///
20/// 1. Use a Specta-based framework which can handle these types
21///     - None currently exist but it would theoretically be possible refer to [#203](https://github.com/specta-rs/specta/issues/203#issuecomment-4387573925) for more information.
22///
23/// 2. Use a smaller integer types (any of `u8`/`i8`/`u16`/`i16`/`u32`/`i32`/`f64`).
24///    - Only possible when the biggest integer you need to represent is small enough to be represented by a `number` in JS.
25///    - This approach forces your application code to handle overflow/underflow values explicitly
26///    - Downside is that it can introduce annoying glue code and doesn't actually work if your need large values.
27///
28/// 3. Serialize the value as a string
29///     - This can be done using `#[specta(type = String)]` for the type combined with a Serde `#[serde(with = "...")]` attribute for runtime.
30///     - Downside is that it can introduce annoying glue code, both on in Rust and in JS as you will need to turn it back into a `new BigInt(myString)` in JS but this will support numbers of any size losslessly.
31///
32/// 4. **UNSAFE:** Accept precision loss on per-field basis
33///     - Accept that large numbers may be deserialized differently than they are in Rust and use `#[specta(type = specta_typescript::Number)]` to bypass this warning on a per-field basis.
34///     - Marking each field explicitly encodes the decision similar to an `unsafe` block, ensuring everyone working on your codebase is aware of the risk and where it exists within the codebase.
35///     - This doesn't work for external implementations like `serde_json::Value` which contain `BigInt`'s as you don't control the definition.
36///
37/// 5. **UNSAFE:** Accept precision loss using [`specta_util::Remapper`](https://docs.rs/specta-util/latest/specta_util/struct.Remapper.html)
38///     - You can apply a `Remapper` to your [`Types`](specta::Types) collection to override types. This would allow you to remap `usize`/`isize`/`i64`/`u64`/`i128`/`u128`/`f128` into `number`.
39///     - This is highly not recommended but it might be required if your using `serde_json::Value` or other built-in impls which contain `BigInt`'s as you can't override them.
40///     - Refer to discussion around this on [#481](https://github.com/specta-rs/specta/issues/481).
41///
42#[non_exhaustive]
43pub struct Error {
44    kind: ErrorKind,
45    named_datatype: Option<Box<NamedDataType>>,
46    trace: Vec<ErrorTraceFrame>,
47}
48
49/// Additional TypeScript exporter context for an [`Error`].
50#[derive(Debug, Clone)]
51#[non_exhaustive]
52pub enum ErrorTraceFrame {
53    /// The exporter was rendering a core-provided inline reference at `path` when the error occurred.
54    Inlined {
55        /// The named Rust type being inlined, if it could be resolved.
56        named_datatype: Option<Box<NamedDataType>>,
57        /// Field, variant, or variant-field path where the inline expansion occurred.
58        path: String,
59    },
60}
61
62type FrameworkSource = Box<dyn error::Error + Send + Sync + 'static>;
63const BIGINT_DOCS_URL: &str =
64    "https://docs.rs/specta-typescript/latest/specta_typescript/struct.Error.html#bigint-forbidden";
65
66#[allow(dead_code)]
67enum ErrorKind {
68    /// A map key type cannot be represented as a valid Typescript index signature.
69    ///
70    /// Typescript map keys must resolve to string-like, number-like, symbol-like, or literal key
71    /// types. Complex structural values cannot safely be represented as object keys.
72    InvalidMapKey {
73        path: String,
74        reason: Cow<'static, str>,
75    },
76    /// Attempted to export a bigint type but the configuration forbids it.
77    BigIntForbidden {
78        path: String,
79    },
80    /// A type's name conflicts with a reserved keyword in Typescript.
81    ForbiddenName {
82        path: String,
83        name: &'static str,
84    },
85    /// A type's name contains invalid characters or is not valid.
86    InvalidName {
87        path: String,
88        name: Cow<'static, str>,
89    },
90    /// A type's name is empty and cannot be emitted as a Typescript type name.
91    EmptyName {
92        path: String,
93    },
94    /// Anonymous enum variants cannot be represented by the Typescript exporter.
95    UnsupportedAnonymousEnumVariant {
96        path: String,
97        variant_kind: &'static str,
98    },
99    /// Detected multiple items within the same scope with the same name.
100    /// Typescript doesn't support this so we error out.
101    ///
102    /// Using anything other than [Layout::FlatFile] should make this basically impossible.
103    DuplicateTypeName {
104        name: Cow<'static, str>,
105        first: String,
106        second: String,
107    },
108    /// An filesystem IO error.
109    /// This is possible when using `Typescript::export_to` when writing to a file or formatting the file.
110    Io(io::Error),
111    /// Failed to read a directory while exporting files.
112    ReadDir {
113        path: PathBuf,
114        source: io::Error,
115    },
116    /// Failed to inspect filesystem metadata while exporting files.
117    Metadata {
118        path: PathBuf,
119        source: io::Error,
120    },
121    /// Failed to remove a stale file while exporting files.
122    RemoveFile {
123        path: PathBuf,
124        source: io::Error,
125    },
126    /// Failed to remove an empty directory while exporting files.
127    RemoveDir {
128        path: PathBuf,
129        source: io::Error,
130    },
131    /// Failed to create an output directory while exporting files.
132    CreateDir {
133        path: PathBuf,
134        source: io::Error,
135    },
136    /// Failed to write an output file while exporting files.
137    WriteFile {
138        path: PathBuf,
139        source: io::Error,
140    },
141    /// Failed to read a generated file while exporting files.
142    ReadFile {
143        path: PathBuf,
144        source: io::Error,
145    },
146    /// Found an opaque reference which the Typescript exporter doesn't know how to handle.
147    /// You may be referencing a type which is not supported by the Typescript exporter.
148    UnsupportedOpaqueReference {
149        path: String,
150        reference: OpaqueReference,
151    },
152    /// Found a named reference that cannot be resolved from the provided
153    /// [`Types`](specta::Types).
154    DanglingNamedReference {
155        path: String,
156        reference: String,
157    },
158    /// Found a recursive named reference marked by core inline resolution.
159    InfiniteRecursiveInlineType {
160        path: String,
161        reference: String,
162        cycle: RecursiveInlineType,
163    },
164    /// Reached the recursion limit while rendering an anonymous Typescript type.
165    InlineRecursionLimitExceeded {
166        path: String,
167    },
168    /// An error occurred in your exporter framework.
169    Framework {
170        message: Cow<'static, str>,
171        source: FrameworkSource,
172    },
173    /// An error occurred in a format callback.
174    Format {
175        message: Cow<'static, str>,
176        path: Option<String>,
177        source: FrameworkSource,
178    },
179    /// The requested export layout is not supported by the current exporter configuration.
180    ///
181    /// Some layouts require the higher-level [`Exporter`](crate::Exporter) APIs so imports,
182    /// file paths, and module boundaries can be coordinated correctly.
183    ExportRequiresExportTo(Layout),
184    JsdocNamespacesUnsupported,
185    /// The type is part of a recursive type-alias cycle (e.g. through
186    /// `#[serde(untagged)]` newtype variants) that TypeScript rejects
187    /// (TS2456) and that the exporter could not collapse into an
188    /// equivalent non-recursive alias.
189    UnrepresentableAliasCycle {
190        path: String,
191        reason: Cow<'static, str>,
192    },
193}
194
195impl Error {
196    fn new(kind: ErrorKind) -> Self {
197        Self {
198            kind,
199            named_datatype: None,
200            trace: Vec::new(),
201        }
202    }
203
204    /// The named Rust type being exported when this error occurred, if known.
205    pub fn named_datatype(&self) -> Option<&NamedDataType> {
206        self.named_datatype.as_deref()
207    }
208
209    /// TypeScript exporter traversal context for this error.
210    pub fn trace(&self) -> &[ErrorTraceFrame] {
211        &self.trace
212    }
213
214    pub(crate) fn with_named_datatype(mut self, ndt: &NamedDataType) -> Self {
215        self.named_datatype
216            .get_or_insert_with(|| Box::new(ndt.clone()));
217        self
218    }
219
220    pub(crate) fn with_inline_trace(
221        mut self,
222        ndt: Option<&NamedDataType>,
223        path: impl Into<String>,
224    ) -> Self {
225        self.trace.push(ErrorTraceFrame::Inlined {
226            named_datatype: ndt.map(|ndt| Box::new(ndt.clone())),
227            path: path.into(),
228        });
229        self
230    }
231
232    pub(crate) fn invalid_map_key(
233        path: impl Into<String>,
234        reason: impl Into<Cow<'static, str>>,
235    ) -> Self {
236        Self::new(ErrorKind::InvalidMapKey {
237            path: path.into(),
238            reason: reason.into(),
239        })
240    }
241
242    /// Construct an error for framework-specific logic.
243    pub fn framework(
244        message: impl Into<Cow<'static, str>>,
245        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
246    ) -> Self {
247        Self::new(ErrorKind::Framework {
248            message: message.into(),
249            source: source.into(),
250        })
251    }
252
253    /// Construct an error for custom format callbacks.
254    pub(crate) fn format(
255        message: impl Into<Cow<'static, str>>,
256        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
257    ) -> Self {
258        Self::new(ErrorKind::Format {
259            message: message.into(),
260            path: None,
261            source: source.into(),
262        })
263    }
264
265    pub(crate) fn format_at(
266        message: impl Into<Cow<'static, str>>,
267        path: impl Into<String>,
268        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
269    ) -> Self {
270        Self::new(ErrorKind::Format {
271            message: message.into(),
272            path: Some(path.into()),
273            source: source.into(),
274        })
275    }
276
277    pub(crate) fn bigint_forbidden(path: String) -> Self {
278        Self::new(ErrorKind::BigIntForbidden { path })
279    }
280
281    pub(crate) fn invalid_name(path: String, name: impl Into<Cow<'static, str>>) -> Self {
282        Self::new(ErrorKind::InvalidName {
283            path,
284            name: name.into(),
285        })
286    }
287
288    pub(crate) fn empty_name(path: String) -> Self {
289        Self::new(ErrorKind::EmptyName { path })
290    }
291
292    pub(crate) fn unsupported_anonymous_enum_variant(
293        path: String,
294        variant_kind: &'static str,
295    ) -> Self {
296        Self::new(ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind })
297    }
298
299    pub(crate) fn forbidden_name(path: String, name: &'static str) -> Self {
300        Self::new(ErrorKind::ForbiddenName { path, name })
301    }
302
303    pub(crate) fn duplicate_type_name(
304        name: Cow<'static, str>,
305        first: Location<'static>,
306        second: Location<'static>,
307    ) -> Self {
308        Self::new(ErrorKind::DuplicateTypeName {
309            name,
310            first: format_location(first),
311            second: format_location(second),
312        })
313    }
314
315    pub(crate) fn read_dir(path: PathBuf, source: io::Error) -> Self {
316        Self::new(ErrorKind::ReadDir { path, source })
317    }
318
319    pub(crate) fn metadata(path: PathBuf, source: io::Error) -> Self {
320        Self::new(ErrorKind::Metadata { path, source })
321    }
322
323    pub(crate) fn remove_file(path: PathBuf, source: io::Error) -> Self {
324        Self::new(ErrorKind::RemoveFile { path, source })
325    }
326
327    pub(crate) fn remove_dir(path: PathBuf, source: io::Error) -> Self {
328        Self::new(ErrorKind::RemoveDir { path, source })
329    }
330
331    pub(crate) fn create_dir(path: PathBuf, source: io::Error) -> Self {
332        Self::new(ErrorKind::CreateDir { path, source })
333    }
334
335    pub(crate) fn write_file(path: PathBuf, source: io::Error) -> Self {
336        Self::new(ErrorKind::WriteFile { path, source })
337    }
338
339    pub(crate) fn read_file(path: PathBuf, source: io::Error) -> Self {
340        Self::new(ErrorKind::ReadFile { path, source })
341    }
342
343    pub(crate) fn unsupported_opaque_reference(path: String, reference: OpaqueReference) -> Self {
344        Self::new(ErrorKind::UnsupportedOpaqueReference { path, reference })
345    }
346
347    pub(crate) fn dangling_named_reference(path: String, reference: String) -> Self {
348        Self::new(ErrorKind::DanglingNamedReference { path, reference })
349    }
350
351    pub(crate) fn infinite_recursive_inline_type(
352        path: String,
353        reference: String,
354        cycle: RecursiveInlineType,
355    ) -> Self {
356        Self::new(ErrorKind::InfiniteRecursiveInlineType {
357            path,
358            reference,
359            cycle,
360        })
361    }
362
363    pub(crate) fn inline_recursion_limit_exceeded(path: String) -> Self {
364        Self::new(ErrorKind::InlineRecursionLimitExceeded { path })
365    }
366
367    pub(crate) fn export_requires_export_to(layout: Layout) -> Self {
368        Self::new(ErrorKind::ExportRequiresExportTo(layout))
369    }
370
371    pub(crate) fn jsdoc_namespaces_unsupported() -> Self {
372        Self::new(ErrorKind::JsdocNamespacesUnsupported)
373    }
374
375    pub(crate) fn unrepresentable_alias_cycle(
376        path: String,
377        reason: impl Into<Cow<'static, str>>,
378    ) -> Self {
379        Self::new(ErrorKind::UnrepresentableAliasCycle {
380            path,
381            reason: reason.into(),
382        })
383    }
384}
385
386impl From<io::Error> for Error {
387    fn from(error: io::Error) -> Self {
388        Self::new(ErrorKind::Io(error))
389    }
390}
391
392impl fmt::Display for Error {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        match &self.kind {
395            ErrorKind::InvalidMapKey { path, reason } => {
396                write!(f, "Invalid map key at '{path}': {reason}")
397            }
398            ErrorKind::BigIntForbidden { path } => write!(
399                f,
400                "Attempted to export {path:?} but Specta forbids exporting BigInt-style types (usize, isize, i64, u64, i128, u128) to avoid precision loss. See {BIGINT_DOCS_URL} for a full explanation."
401            ),
402            ErrorKind::ForbiddenName { path, name } => write!(
403                f,
404                "Attempted to export {} but was unable to due to name {name:?} conflicting with a reserved keyword in Typescript. Try renaming it or using `#[specta(rename = \"new name\")]`",
405                display_path(path)
406            ),
407            ErrorKind::InvalidName { path, name } => write!(
408                f,
409                "Attempted to export {} but was unable to due to name {name:?} containing an invalid character. Try renaming it or using `#[specta(rename = \"new name\")]`",
410                display_path(path)
411            ),
412            ErrorKind::EmptyName { path } => write!(
413                f,
414                "Attempted to export {} but was unable to because the Typescript type name is empty. Try renaming it or using `#[specta(rename = \"new name\")]`",
415                display_path(path)
416            ),
417            ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind } => write!(
418                f,
419                "Attempted to export {} but anonymous {variant_kind} enum variants cannot be exported to Typescript. Try giving the variant a name or changing the enum representation.",
420                display_path(path)
421            ),
422            ErrorKind::DuplicateTypeName {
423                name,
424                first,
425                second,
426            } => write!(
427                f,
428                "Detected multiple types with the same name: {name:?} at {first} and {second}"
429            ),
430            ErrorKind::Io(err) => write!(f, "IO error: {err}"),
431            ErrorKind::ReadDir { path, source } => {
432                write!(f, "Failed to read directory '{}': {source}", path.display())
433            }
434            ErrorKind::Metadata { path, source } => {
435                write!(
436                    f,
437                    "Failed to read metadata for '{}': {source}",
438                    path.display()
439                )
440            }
441            ErrorKind::RemoveFile { path, source } => {
442                write!(f, "Failed to remove file '{}': {source}", path.display())
443            }
444            ErrorKind::RemoveDir { path, source } => {
445                write!(
446                    f,
447                    "Failed to remove directory '{}': {source}",
448                    path.display()
449                )
450            }
451            ErrorKind::CreateDir { path, source } => {
452                write!(
453                    f,
454                    "Failed to create directory '{}': {source}",
455                    path.display()
456                )
457            }
458            ErrorKind::WriteFile { path, source } => {
459                write!(f, "Failed to write file '{}': {source}", path.display())
460            }
461            ErrorKind::ReadFile { path, source } => {
462                write!(f, "Failed to read file '{}': {source}", path.display())
463            }
464            ErrorKind::UnsupportedOpaqueReference { path, reference } => write!(
465                f,
466                "Found unsupported opaque reference '{}' at {}. It is not supported by the Typescript exporter.",
467                reference.type_name(),
468                display_path(path)
469            ),
470            ErrorKind::DanglingNamedReference { path, reference } => write!(
471                f,
472                "Found dangling named reference {reference} at {}. The referenced type is missing from the resolved type collection.",
473                display_path(path)
474            ),
475            ErrorKind::InfiniteRecursiveInlineType {
476                path,
477                reference,
478                cycle,
479            } => {
480                write!(
481                    f,
482                    "Found infinitely recursive inline named reference {reference} at {}. Recursive inline types cannot be expanded because they would produce an infinite Typescript type.",
483                    display_path(path)
484                )?;
485                write!(f, "\nInline cycle:\n  {cycle:?}")?;
486                Ok(())
487            }
488            ErrorKind::InlineRecursionLimitExceeded { path } if path.is_empty() => write!(
489                f,
490                "Type recursion limit exceeded while expanding the provided inline type. Recursive inline types cannot be expanded because they would produce an infinite Typescript type."
491            ),
492            ErrorKind::InlineRecursionLimitExceeded { path } => write!(
493                f,
494                "Type recursion limit exceeded while expanding an inline Typescript type at {}. Recursive inline types cannot be expanded because they would produce an infinite Typescript type.",
495                display_path(path)
496            ),
497            ErrorKind::Framework { message, source } => {
498                let source = source.to_string();
499                if message.is_empty() && source.is_empty() {
500                    write!(f, "Framework error")
501                } else if source.is_empty() {
502                    write!(f, "Framework error: {message}")
503                } else {
504                    write!(f, "Framework error: {message}: {source}")
505                }
506            }
507            ErrorKind::Format {
508                message,
509                path,
510                source,
511            } => {
512                let source = source.to_string();
513                let location = path
514                    .as_deref()
515                    .filter(|path| !path.is_empty())
516                    .map(|path| format!(" at {}", display_path(path)))
517                    .unwrap_or_default();
518                if message.is_empty() && source.is_empty() {
519                    write!(f, "Format error{location}")
520                } else if source.is_empty() {
521                    write!(f, "Format error{location}: {message}")
522                } else {
523                    write!(f, "Format error{location}: {message}: {source}")
524                }
525            }
526            ErrorKind::ExportRequiresExportTo(layout) => write!(
527                f,
528                "Unable to export layout {layout} as a single string. Use `Exporter::export_to` with a directory path for file-based exports."
529            ),
530            ErrorKind::JsdocNamespacesUnsupported => write!(
531                f,
532                "Unable to export JSDoc with the Namespaces layout. Disable JSDoc or use FlatFile, ModulePrefixedName, or Files layout."
533            ),
534            ErrorKind::UnrepresentableAliasCycle { path, reason } => write!(
535                f,
536                "Attempted to export {} but it is part of a recursive type-alias cycle that cannot be collapsed into valid TypeScript: {reason}. TypeScript rejects self-referential type aliases (TS2456); restructure the type so the recursion passes through an object, array, or tuple instead of a bare (untagged/transparent) reference.",
537                display_path(path)
538            ),
539        }?;
540
541        if let Some(ndt) = self.named_datatype() {
542            write!(
543                f,
544                "\nRust type: {}::{} at {}",
545                ndt.module_path,
546                ndt.name,
547                format_location(ndt.location)
548            )?;
549        }
550
551        if !self.trace.is_empty() {
552            write!(f, "\nWhile inlining:")?;
553            for frame in self.trace.iter().rev() {
554                match frame {
555                    ErrorTraceFrame::Inlined {
556                        named_datatype,
557                        path,
558                    } => {
559                        write!(f, "\n  {path} -> ")?;
560                        if let Some(ndt) = named_datatype.as_deref() {
561                            write!(f, "{}::{}", ndt.module_path, ndt.name)?;
562                        } else {
563                            write!(f, "<unresolved named type>")?;
564                        }
565                    }
566                }
567            }
568        }
569
570        Ok(())
571    }
572}
573
574impl fmt::Debug for Error {
575    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
576        fmt::Display::fmt(self, f)
577    }
578}
579
580impl error::Error for Error {
581    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
582        match &self.kind {
583            ErrorKind::Io(error) => Some(error),
584            ErrorKind::ReadDir { source, .. }
585            | ErrorKind::Metadata { source, .. }
586            | ErrorKind::RemoveFile { source, .. }
587            | ErrorKind::RemoveDir { source, .. }
588            | ErrorKind::CreateDir { source, .. }
589            | ErrorKind::WriteFile { source, .. }
590            | ErrorKind::ReadFile { source, .. } => Some(source),
591            ErrorKind::Framework { source, .. } | ErrorKind::Format { source, .. } => {
592                Some(source.as_ref())
593            }
594            _ => None,
595        }
596    }
597}
598
599fn format_location(location: Location<'static>) -> String {
600    format!(
601        "{}:{}:{}",
602        location.file(),
603        location.line(),
604        location.column()
605    )
606}
607
608fn display_path(path: &str) -> Cow<'_, str> {
609    if path.is_empty() {
610        Cow::Borrowed("<unknown path>")
611    } else {
612        Cow::Owned(format!("{path:?}"))
613    }
614}