Skip to content

Breakout Generated Types into TS Modules to Avoid Name Collisions #7

@EvanPiro

Description

@EvanPiro

Breakout Generated Types into TS Modules to Avoid Name Collisions

Problem

writeTypeScriptLibrary collects all TSDeclarations from the API and writes them into a single client.d.ts file via formatTSDeclarations. The generated declarations are ambient (no import or export), so they all occupy the global TypeScript namespace. Two Haskell types from different modules that share a name produce a duplicate identifier error.

For example, given two Haskell modules that both define a Status type:

-- Foo.hs
data Status = Active | Inactive

-- Bar.hs
data Result = Ok Status | Err String
data Status = Status { code :: Int, message :: String }

The generated output collides:

// client.d.ts (generated, ambient declarations)

type Status = "Active" | "Inactive";
//   ^^^^^^  Duplicate identifier 'Status'.

interface IStatus {
//        ^^^^^^^  Duplicate identifier 'IStatus'.
  code: number;
  message: string;
}
type Status = IStatus;

type Result = IResultOk | IResultErr;

Simply splitting into multiple .d.ts files would not help — ambient declarations are still merged into the global namespace regardless of which file they appear in. The fix requires generating proper ES modules with export statements and import type references between them.

Proposed Solution

Generate one TypeScript module (with export) per Haskell module, with import type statements to resolve cross-module references.

Expected output structure

Instead of a single ambient client.d.ts, the library would produce:

types/Foo.d.ts:

export type Status = "Active" | "Inactive";

types/Bar.d.ts:

import type { Status } from './Foo';

export interface IStatus {
  code: number;
  message: string;
}
export type Status = IStatus;

export type Result = IResultOk | IResultErr;

The two Status declarations no longer collide because each file is an ES module (due to the export keyword) with its own scope.

What this would require

  1. Module provenance on declarationsaeson-typescript's TSDeclaration currently carries the type/interface name but not the originating Haskell module. The library would need some way to associate each declaration with its source module — either by extending TSDeclaration with module metadata, adding a method to the TypeScript class, or accepting module annotations from the consumer.

  2. export on all declarations — The current formatTSDeclarations emits ambient declarations (no export). A new formatter or formatting option would need to emit export on each declaration so the output files are proper ES modules.

  3. Cross-module import resolution — On the servant-typescript side, writeClientTypes would need to:

    • Group declarations by source module
    • Build an export index (Map Identifier ModulePath) from the grouped declarations
    • For each module, resolve cross-module references and render relative import type statements
    • Write each module to its own .d.ts file under the output directory

A ServantTypeScriptOptions field (e.g. typeFileLayout :: TypeFileLayout with SingleFile | PerModule variants) could keep this backwards-compatible.

Workaround

Currently the only workaround is to avoid name collisions at the Haskell level (renaming types or suppressing generation), which doesn't scale.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions