Skip to content

Repository files navigation

ts-remote

npm version

Share TypeScript types between independently deployed applications.

ts-remote bundles your TypeScript source into a single ambient .d.ts file, publishes it like any other static asset, and lets consuming projects resolve those types at design time — without importing the runtime code, without a shared monorepo, and without publishing a package to npm.

It's built for microfrontend and multi-repo architectures, where each app is deployed on its own but still needs to agree on the shape of the data and APIs they exchange.


How it works

┌─────────────────┐     build      ┌──────────────┐    serve     ┌──────────────────┐
│  Producer app   │  ──────────▶   │  types.d.ts  │  ─────────▶  │   CDN / static   │
│  (TS source)    │   (builder)    │  (ambient)   │              │     hosting      │
└─────────────────┘                └──────────────┘              └────────┬─────────┘
                                                                          │ fetch
                                                                          ▼
                                              ┌──────────────────────────────────────┐
                                              │            Consumer app               │
                                              │  ts-remote fetch  →  local cache       │
                                              │  TS plugin        →  resolves imports  │
                                              └──────────────────────────────────────┘
  1. Producer runs the builder to emit a consolidated declare module "..." file from its source.
  2. The .d.ts is served over HTTP(S) — a CDN, object storage, or any static host.
  3. Consumer runs the fetcher (CLI or API) to download and cache it, and the TS Language Service plugin patches module resolution so import { User } from "my-app" type-checks against the remote types.

The three pieces are independent — use only what you need.


Installation

npm install -D ts-remote

Each part has its own entry point:

Import Used by Purpose
ts-remote/builder Producer Generate the .d.ts bundle
ts-remote/fetcher Consumer Download & cache remote .d.ts (programmatic)
ts-remote/plugin Consumer Resolve remote modules in the editor / tsc
ts-remote (CLI) Consumer ts-remote fetch

Producer: building types

import build from 'ts-remote/builder';

await build({
  entries: [
    { name: 'my-app', filename: './src/index.ts' },
    { name: 'utils', filename: './src/utils.ts' },
  ],
  output: {
    filename: './dist/types.d.ts',
  },
});

./src/index.ts:

export interface User {
  id: string;
  name: string;
}

export function getUser(id: string): Promise<User>;

Generated ./dist/types.d.ts:

declare module "my-app" {
  export interface User {
    id: string;
    name: string;
  }
  export function getUser(id: string): Promise<User>;
}

declare module "utils" {
  export function formatDate(date: Date): string;
}

The output is a self-contained ambient declaration: imports of local files are inlined, private/protected members are stripped, and internal name collisions are de-duplicated. Publish dist/types.d.ts wherever your consumers can reach it over HTTP(S).

Formatting the output

output.format receives the generated string and returns the final contents — handy for running it through Prettier:

import build from 'ts-remote/builder';
import prettier from 'prettier';

await build({
  entries: [{ name: 'my-app', filename: './src/index.ts' }],
  output: {
    filename: './dist/types.d.ts',
    format: (code) => prettier.format(code, { parser: 'typescript' }),
  },
});

Module vs. Namespace

By default each entry becomes a declare module "name". Pass DeclarationVariant.Namespace to emit a declare namespace instead — useful for ambient globals rather than importable modules:

import build, { DeclarationVariant } from 'ts-remote/builder';

await build({
  entries: [
    { name: 'MyLib', filename: './src/index.ts', variant: DeclarationVariant.Namespace },
  ],
});
declare namespace MyLib {
  export interface Config { /* ... */ }
}

build(options: BuilderOptions): Promise<void>

Property Type Default Description
entries DeclarationEntry[] Modules to compile (required)
output.filename string @types/types.d.ts Output file path
output.format (result: string) => string | Promise<string> (x) => x Post-processor for the output (e.g. Prettier)
tsconfig string tsconfig.json Path to the producer's tsconfig
additionalDeclarations AdditionalDeclaration[] [] Extra .d.ts files for ambient context and (by default) concatenation into the output
type DeclarationEntry = {
  name: string;                 // the module name used in `declare module "name"`
  filename: string;             // entry source file
  variant?: DeclarationVariant; // Module (default) | Namespace
};

type AdditionalDeclaration =
  | string                                    // path to a .d.ts: environment + shipped in the output
  | { filename: string; emit?: boolean };     // emit: false → environment-only, not shipped

Global declarations

If your source relies on global ambient types — say a Window augmentation — pass the .d.ts via additionalDeclarations. The file becomes part of the compilation environment (so the emitted types resolve correctly) and its content is concatenated verbatim at the top of the output, so consumers receive the globals too:

// global.d.ts
interface Bar { baz: string; }
interface Window { foo: Bar; }

// getBar.ts
export const getBar = () => window.foo;
await build({
  entries: [{ name: 'my-app', filename: './src/getBar.ts' }],
  additionalDeclarations: ['./src/global.d.ts'],
});
// output: globals first, then the module blocks
interface Bar { baz: string; }
interface Window { foo: Bar; }
declare module 'my-app' {
  const getBar: () => Bar; // ✅ Bar resolves on the consumer side
  export { getBar };
}

Without additionalDeclarations the compiler wouldn't see global.d.ts at all (the program is built from the entry files only) and getBar would degrade to () => any. Use { filename, emit: false } for declarations that are needed to compile but shouldn't be shipped — e.g. environment types the consumer is guaranteed to have already.

A declare global { ... } block written directly inside an entry module is handled automatically — no additionalDeclarations needed. It goes through the emitter and is preserved as a global {} augmentation inside the module block, which stays a valid global augmentation on the consumer side:

// getBar.ts
interface Bar { baz: string; }
declare global {
  interface Window { foo: Bar; }
}
export const getBar = () => window.foo;
declare module 'my-app' {
  interface Bar { baz: string; }
  global {
    interface Window { foo: Bar; }
  }
  const getBar: () => Bar;
  export { getBar };
}

Consumer: fetching types

Point the consumer at the published .d.ts files. Configure remotes once in the consumer's tsconfig.json (used by both the CLI and the editor plugin):

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "ts-remote",
        "remotes": {
          "my-app": "https://cdn.example.com/my-app/types.d.ts",
          "@shared/ui": "https://cdn.example.com/shared-ui/types.d.ts"
        },
        "cacheDir": "node_modules/.ts-remote",
        "cacheTTL": 300000,
        "tls": {
          "ca": "./certs/internal-ca.pem",
          "cert": "./certs/client-cert.pem",
          "key": "./certs/client-key.pem"
        },
        "headers": {
          "Authorization": "Bearer ${TYPES_REGISTRY_TOKEN}"
        }
      }
    ]
  }
}

tls is optional and only needed if a remote requires a client certificate (mutual TLS) or a self-signed/internal CA that Node doesn't trust by default:

Property Type Description
tls.ca string Path to a trusted CA certificate, relative to this tsconfig
tls.cert / tls.key string Path to a client certificate/key for mutual TLS
tls.pfx string Path to a PKCS#12 bundle, as an alternative to cert/key
tls.passphrase string Passphrase for key or pfx
tls.rejectUnauthorized boolean Verify the server's certificate (true by default)

headers is optional and adds request headers, e.g. a token for a private host. Values may reference environment variables as ${VAR_NAME} — resolved at fetch time, so secrets stay out of the committed tsconfig (an unset variable is an error). Authorization, Cookie and Proxy-Authorization are automatically dropped if a redirect leaves the original origin.

CLI

Download every configured remote into the local cache:

ts-remote fetch
Flag Default Description
--config <path> ./tsconfig.json Path to the tsconfig holding the ts-remote plugin config
--cache-dir <path> node_modules/.ts-remote/ Where cached .d.ts files are written
--force false Re-fetch all remotes, ignoring the cache TTL. Also disables the stale-cache fallback: fresh types or a hard failure
--help Show usage

If a remote is temporarily unreachable and an expired copy exists in the cache, fetch (without --force) logs a warning and keeps the stale copy instead of failing — so a flaky CDN doesn't break postinstall or CI.

Run it in a postinstall script or in CI before tsc so the cache is warm:

{
  "scripts": {
    "postinstall": "ts-remote fetch"
  }
}

Programmatic fetcher

The CLI is a thin wrapper over the fetcher API, which you can call directly:

import fetch from 'ts-remote/fetcher';

const results = await fetch({
  remotes: {
    'my-app': 'https://cdn.example.com/my-app/types.d.ts',
  },
});

console.log(results[0].cachedPath); // node_modules/.ts-remote/my-app.d.ts
console.log(results[0].fromCache);  // false on first run, true while within TTL

FetcherOptions:

Property Type Default Description
remotes Record<string, string> Map of module name → .d.ts URL (required)
cacheDir string node_modules/.ts-remote Cache directory
cacheTTL number 300000 (5 min) Cache lifetime in ms. 0 = always re-fetch, Infinity = never
retries number 2 Retry attempts on network failure (4xx is not retried)
timeout number 10000 Per-request timeout in ms
maxRedirects number 5 Maximum redirects to follow per request
logLevel LogLevel Info Verbosity
tls TlsOptions Client cert, custom CA, etc — forwarded to Node's https client
headers Record<string, string> Extra request headers (e.g. Authorization); credentials are dropped on cross-origin redirects
staleIfError boolean true On fetch failure, fall back to an expired cached copy (with a warning) instead of throwing

Each FetchResult is { name, url, cachedPath, fromCache, stale? }stale: true marks a result served from expired cache because the fetch failed.

Remotes are fetched in parallel. When an expired cache entry has an ETag from the server, the re-fetch is conditional (If-None-Match): a 304 Not Modified revalidates the cached copy without re-downloading. Responses that are empty or look like an HTML error page are rejected rather than cached.

Calling the fetcher directly (rather than through the tsconfig.json plugin config) is the same escape hatch used above, except tls.ca/cert/key/pfx take the certificate content itself (string | Buffer), not a file path — read the file yourself:

import fs from 'node:fs';
import fetch from 'ts-remote/fetcher';

const results = await fetch({
  remotes: {
    'my-app': 'https://internal.example.com/my-app/types.d.ts',
  },
  tls: {
    ca: fs.readFileSync('./certs/internal-ca.pem'),
    cert: fs.readFileSync('./certs/client-cert.pem'),
    key: fs.readFileSync('./certs/client-key.pem'),
  },
});

Editor & build-time resolution (plugin)

The ts-remote plugin is a TypeScript Language Service Plugin. With the tsconfig.json above in place, your editor resolves remote imports against the cached declarations:

import { User, getUser } from 'my-app'; // ✅ typed from the remote .d.ts

Editor note: Language Service Plugins run inside the TypeScript server your editor ships with. In VS Code, select "Use Workspace Version" of TypeScript so the plugin is loaded. On startup the plugin loads types from the cache populated by ts-remote fetch — so fetch first — and then refreshes stale or missing remotes in the background, honoring the same remotes, cacheDir, cacheTTL, tls and headers settings.


End-to-end example

Producer (shared-types app) publishes its contracts:

// build.ts — run on deploy, upload dist/types.d.ts to your CDN
import build from 'ts-remote/builder';

await build({
  entries: [{ name: '@acme/orders', filename: './src/public-api.ts' }],
  output: { filename: './dist/types.d.ts' },
});

Consumer (checkout app) opts in:

// tsconfig.json
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "ts-remote",
        "remotes": { "@acme/orders": "https://cdn.acme.dev/orders/types.d.ts" }
      }
    ]
  }
}
ts-remote fetch        # warm the cache
// checkout/src/cart.ts
import type { Order } from '@acme/orders'; // resolved from the fetched .d.ts

function total(order: Order): number { /* ... */ }

See examples/ for runnable builder examples, including the http-client abstraction.


Requirements

  • TypeScript >= 4.9
  • Node.js >= 18

Links

License

MIT © Denis Arkhipov

About

No description, website, or topics provided.

Resources

Contributing

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages