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}
186
187impl Error {
188    fn new(kind: ErrorKind) -> Self {
189        Self {
190            kind,
191            named_datatype: None,
192            trace: Vec::new(),
193        }
194    }
195
196    /// The named Rust type being exported when this error occurred, if known.
197    pub fn named_datatype(&self) -> Option<&NamedDataType> {
198        self.named_datatype.as_deref()
199    }
200
201    /// TypeScript exporter traversal context for this error.
202    pub fn trace(&self) -> &[ErrorTraceFrame] {
203        &self.trace
204    }
205
206    pub(crate) fn with_named_datatype(mut self, ndt: &NamedDataType) -> Self {
207        self.named_datatype
208            .get_or_insert_with(|| Box::new(ndt.clone()));
209        self
210    }
211
212    pub(crate) fn with_inline_trace(
213        mut self,
214        ndt: Option<&NamedDataType>,
215        path: impl Into<String>,
216    ) -> Self {
217        self.trace.push(ErrorTraceFrame::Inlined {
218            named_datatype: ndt.map(|ndt| Box::new(ndt.clone())),
219            path: path.into(),
220        });
221        self
222    }
223
224    pub(crate) fn invalid_map_key(
225        path: impl Into<String>,
226        reason: impl Into<Cow<'static, str>>,
227    ) -> Self {
228        Self::new(ErrorKind::InvalidMapKey {
229            path: path.into(),
230            reason: reason.into(),
231        })
232    }
233
234    /// Construct an error for framework-specific logic.
235    pub fn framework(
236        message: impl Into<Cow<'static, str>>,
237        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
238    ) -> Self {
239        Self::new(ErrorKind::Framework {
240            message: message.into(),
241            source: source.into(),
242        })
243    }
244
245    /// Construct an error for custom format callbacks.
246    pub(crate) fn format(
247        message: impl Into<Cow<'static, str>>,
248        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
249    ) -> Self {
250        Self::new(ErrorKind::Format {
251            message: message.into(),
252            path: None,
253            source: source.into(),
254        })
255    }
256
257    pub(crate) fn format_at(
258        message: impl Into<Cow<'static, str>>,
259        path: impl Into<String>,
260        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
261    ) -> Self {
262        Self::new(ErrorKind::Format {
263            message: message.into(),
264            path: Some(path.into()),
265            source: source.into(),
266        })
267    }
268
269    pub(crate) fn bigint_forbidden(path: String) -> Self {
270        Self::new(ErrorKind::BigIntForbidden { path })
271    }
272
273    pub(crate) fn invalid_name(path: String, name: impl Into<Cow<'static, str>>) -> Self {
274        Self::new(ErrorKind::InvalidName {
275            path,
276            name: name.into(),
277        })
278    }
279
280    pub(crate) fn empty_name(path: String) -> Self {
281        Self::new(ErrorKind::EmptyName { path })
282    }
283
284    pub(crate) fn unsupported_anonymous_enum_variant(
285        path: String,
286        variant_kind: &'static str,
287    ) -> Self {
288        Self::new(ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind })
289    }
290
291    pub(crate) fn forbidden_name(path: String, name: &'static str) -> Self {
292        Self::new(ErrorKind::ForbiddenName { path, name })
293    }
294
295    pub(crate) fn duplicate_type_name(
296        name: Cow<'static, str>,
297        first: Location<'static>,
298        second: Location<'static>,
299    ) -> Self {
300        Self::new(ErrorKind::DuplicateTypeName {
301            name,
302            first: format_location(first),
303            second: format_location(second),
304        })
305    }
306
307    pub(crate) fn read_dir(path: PathBuf, source: io::Error) -> Self {
308        Self::new(ErrorKind::ReadDir { path, source })
309    }
310
311    pub(crate) fn metadata(path: PathBuf, source: io::Error) -> Self {
312        Self::new(ErrorKind::Metadata { path, source })
313    }
314
315    pub(crate) fn remove_file(path: PathBuf, source: io::Error) -> Self {
316        Self::new(ErrorKind::RemoveFile { path, source })
317    }
318
319    pub(crate) fn remove_dir(path: PathBuf, source: io::Error) -> Self {
320        Self::new(ErrorKind::RemoveDir { path, source })
321    }
322
323    pub(crate) fn create_dir(path: PathBuf, source: io::Error) -> Self {
324        Self::new(ErrorKind::CreateDir { path, source })
325    }
326
327    pub(crate) fn write_file(path: PathBuf, source: io::Error) -> Self {
328        Self::new(ErrorKind::WriteFile { path, source })
329    }
330
331    pub(crate) fn read_file(path: PathBuf, source: io::Error) -> Self {
332        Self::new(ErrorKind::ReadFile { path, source })
333    }
334
335    pub(crate) fn unsupported_opaque_reference(path: String, reference: OpaqueReference) -> Self {
336        Self::new(ErrorKind::UnsupportedOpaqueReference { path, reference })
337    }
338
339    pub(crate) fn dangling_named_reference(path: String, reference: String) -> Self {
340        Self::new(ErrorKind::DanglingNamedReference { path, reference })
341    }
342
343    pub(crate) fn infinite_recursive_inline_type(
344        path: String,
345        reference: String,
346        cycle: RecursiveInlineType,
347    ) -> Self {
348        Self::new(ErrorKind::InfiniteRecursiveInlineType {
349            path,
350            reference,
351            cycle,
352        })
353    }
354
355    pub(crate) fn inline_recursion_limit_exceeded(path: String) -> Self {
356        Self::new(ErrorKind::InlineRecursionLimitExceeded { path })
357    }
358
359    pub(crate) fn export_requires_export_to(layout: Layout) -> Self {
360        Self::new(ErrorKind::ExportRequiresExportTo(layout))
361    }
362
363    pub(crate) fn jsdoc_namespaces_unsupported() -> Self {
364        Self::new(ErrorKind::JsdocNamespacesUnsupported)
365    }
366}
367
368impl From<io::Error> for Error {
369    fn from(error: io::Error) -> Self {
370        Self::new(ErrorKind::Io(error))
371    }
372}
373
374impl fmt::Display for Error {
375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
376        match &self.kind {
377            ErrorKind::InvalidMapKey { path, reason } => {
378                write!(f, "Invalid map key at '{path}': {reason}")
379            }
380            ErrorKind::BigIntForbidden { path } => write!(
381                f,
382                "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."
383            ),
384            ErrorKind::ForbiddenName { path, name } => write!(
385                f,
386                "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\")]`",
387                display_path(path)
388            ),
389            ErrorKind::InvalidName { path, name } => write!(
390                f,
391                "Attempted to export {} but was unable to due to name {name:?} containing an invalid character. Try renaming it or using `#[specta(rename = \"new name\")]`",
392                display_path(path)
393            ),
394            ErrorKind::EmptyName { path } => write!(
395                f,
396                "Attempted to export {} but was unable to because the Typescript type name is empty. Try renaming it or using `#[specta(rename = \"new name\")]`",
397                display_path(path)
398            ),
399            ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind } => write!(
400                f,
401                "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.",
402                display_path(path)
403            ),
404            ErrorKind::DuplicateTypeName {
405                name,
406                first,
407                second,
408            } => write!(
409                f,
410                "Detected multiple types with the same name: {name:?} at {first} and {second}"
411            ),
412            ErrorKind::Io(err) => write!(f, "IO error: {err}"),
413            ErrorKind::ReadDir { path, source } => {
414                write!(f, "Failed to read directory '{}': {source}", path.display())
415            }
416            ErrorKind::Metadata { path, source } => {
417                write!(
418                    f,
419                    "Failed to read metadata for '{}': {source}",
420                    path.display()
421                )
422            }
423            ErrorKind::RemoveFile { path, source } => {
424                write!(f, "Failed to remove file '{}': {source}", path.display())
425            }
426            ErrorKind::RemoveDir { path, source } => {
427                write!(
428                    f,
429                    "Failed to remove directory '{}': {source}",
430                    path.display()
431                )
432            }
433            ErrorKind::CreateDir { path, source } => {
434                write!(
435                    f,
436                    "Failed to create directory '{}': {source}",
437                    path.display()
438                )
439            }
440            ErrorKind::WriteFile { path, source } => {
441                write!(f, "Failed to write file '{}': {source}", path.display())
442            }
443            ErrorKind::ReadFile { path, source } => {
444                write!(f, "Failed to read file '{}': {source}", path.display())
445            }
446            ErrorKind::UnsupportedOpaqueReference { path, reference } => write!(
447                f,
448                "Found unsupported opaque reference '{}' at {}. It is not supported by the Typescript exporter.",
449                reference.type_name(),
450                display_path(path)
451            ),
452            ErrorKind::DanglingNamedReference { path, reference } => write!(
453                f,
454                "Found dangling named reference {reference} at {}. The referenced type is missing from the resolved type collection.",
455                display_path(path)
456            ),
457            ErrorKind::InfiniteRecursiveInlineType {
458                path,
459                reference,
460                cycle,
461            } => {
462                write!(
463                    f,
464                    "Found infinitely recursive inline named reference {reference} at {}. Recursive inline types cannot be expanded because they would produce an infinite Typescript type.",
465                    display_path(path)
466                )?;
467                write!(f, "\nInline cycle:\n  {cycle:?}")?;
468                Ok(())
469            }
470            ErrorKind::InlineRecursionLimitExceeded { path } if path.is_empty() => write!(
471                f,
472                "Type recursion limit exceeded while expanding the provided inline type. Recursive inline types cannot be expanded because they would produce an infinite Typescript type."
473            ),
474            ErrorKind::InlineRecursionLimitExceeded { path } => write!(
475                f,
476                "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.",
477                display_path(path)
478            ),
479            ErrorKind::Framework { message, source } => {
480                let source = source.to_string();
481                if message.is_empty() && source.is_empty() {
482                    write!(f, "Framework error")
483                } else if source.is_empty() {
484                    write!(f, "Framework error: {message}")
485                } else {
486                    write!(f, "Framework error: {message}: {source}")
487                }
488            }
489            ErrorKind::Format {
490                message,
491                path,
492                source,
493            } => {
494                let source = source.to_string();
495                let location = path
496                    .as_deref()
497                    .filter(|path| !path.is_empty())
498                    .map(|path| format!(" at {}", display_path(path)))
499                    .unwrap_or_default();
500                if message.is_empty() && source.is_empty() {
501                    write!(f, "Format error{location}")
502                } else if source.is_empty() {
503                    write!(f, "Format error{location}: {message}")
504                } else {
505                    write!(f, "Format error{location}: {message}: {source}")
506                }
507            }
508            ErrorKind::ExportRequiresExportTo(layout) => write!(
509                f,
510                "Unable to export layout {layout} as a single string. Use `Exporter::export_to` with a directory path for file-based exports."
511            ),
512            ErrorKind::JsdocNamespacesUnsupported => write!(
513                f,
514                "Unable to export JSDoc with the Namespaces layout. Disable JSDoc or use FlatFile, ModulePrefixedName, or Files layout."
515            ),
516        }?;
517
518        if let Some(ndt) = self.named_datatype() {
519            write!(
520                f,
521                "\nRust type: {}::{} at {}",
522                ndt.module_path,
523                ndt.name,
524                format_location(ndt.location)
525            )?;
526        }
527
528        if !self.trace.is_empty() {
529            write!(f, "\nWhile inlining:")?;
530            for frame in self.trace.iter().rev() {
531                match frame {
532                    ErrorTraceFrame::Inlined {
533                        named_datatype,
534                        path,
535                    } => {
536                        write!(f, "\n  {path} -> ")?;
537                        if let Some(ndt) = named_datatype.as_deref() {
538                            write!(f, "{}::{}", ndt.module_path, ndt.name)?;
539                        } else {
540                            write!(f, "<unresolved named type>")?;
541                        }
542                    }
543                }
544            }
545        }
546
547        Ok(())
548    }
549}
550
551impl fmt::Debug for Error {
552    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553        fmt::Display::fmt(self, f)
554    }
555}
556
557impl error::Error for Error {
558    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
559        match &self.kind {
560            ErrorKind::Io(error) => Some(error),
561            ErrorKind::ReadDir { source, .. }
562            | ErrorKind::Metadata { source, .. }
563            | ErrorKind::RemoveFile { source, .. }
564            | ErrorKind::RemoveDir { source, .. }
565            | ErrorKind::CreateDir { source, .. }
566            | ErrorKind::WriteFile { source, .. }
567            | ErrorKind::ReadFile { source, .. } => Some(source),
568            ErrorKind::Framework { source, .. } | ErrorKind::Format { source, .. } => {
569                Some(source.as_ref())
570            }
571            _ => None,
572        }
573    }
574}
575
576fn format_location(location: Location<'static>) -> String {
577    format!(
578        "{}:{}:{}",
579        location.file(),
580        location.line(),
581        location.column()
582    )
583}
584
585fn display_path(path: &str) -> Cow<'_, str> {
586    if path.is_empty() {
587        Cow::Borrowed("<unknown path>")
588    } else {
589        Cow::Owned(format!("{path:?}"))
590    }
591}