Sharing TypeScript Types Across Federated Remotes #
When a host imports remote_app/Widget, Module Federation resolves that module at runtime — so the TypeScript compiler sees a module that does not exist on disk at build time, leaves the import typed as any, and silently strips away every guarantee you rely on.
This guide walks through two complementary ways to restore compile-time type safety across Configuring Webpack Module Federation boundaries: automatically emitting and consuming remote .d.ts files, and publishing a shared types package that both sides depend on. You will also wire a CI step so types never drift from the running contract.
Prerequisites #
This guide assumes a working federated setup. Specific versions and assumptions:
- TypeScript 5.2+ on both host and remote, with
"declaration": trueavailable. - Webpack 5.88+ with
ModuleFederationPlugin, or@module-federation/enhanced0.6+ for the built-in dts plugin. - A host that imports remote modules via the
remotesmap (for exampleremote_app: 'remote_app@https://remotes.example.com/remoteEntry.js'). - A package registry or workspace (npm, GitHub Packages, or a pnpm/Yarn monorepo) reachable from CI.
strict: trueintsconfig.jsonso untypedanyimports surface as real failures.
You do not need both options below. Option A is the lowest-friction path; option B gives you the strongest guarantees and works even across mixed Webpack/Vite toolchains.
Step 1 — Confirm the type gap exists #
Before fixing anything, reproduce the problem so you can verify the fix later. Add a remote import and run the compiler.
// host/src/App.tsx
import Widget from 'remote_app/Widget';
export function App() {
// Today this compiles even though `count` may not exist on the remote.
return <Widget count={42} />;
}
npx tsc --noEmit
# error TS2307: Cannot find module 'remote_app/Widget' or its
# corresponding type declarations.
If you instead see no error and your editor shows Widget: any, that is the silent failure mode — the module slipped through because of a wildcard declare module shim or skipLibCheck. Either way, the goal is to make tsc aware of the real shape of Widget.
Why does this happen at all? Module Federation rewrites import('remote_app/Widget') into a runtime lookup against the shared scope and the remote’s remoteEntry.js. None of that exists when the compiler runs — there is no file, no package.json types entry, and no node_modules folder for remote_app. TypeScript treats the specifier as an unresolved module, so unless you supply declarations out of band, you lose prop checking, autocomplete, and refactor safety across the most important boundary in the system. The two options below close that gap from opposite directions: Option A automates declaration transfer, Option B makes the contract an explicit, versioned artifact.
Step 2 (Option A) — Emit and consume remote .d.ts automatically #
The @module-federation/enhanced plugin can generate a type bundle for everything a remote exposes and let the host pull it during build. This is the fastest route when both apps are on Module Federation.
On the remote, enable dts generation:
// remote/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'remote_app',
filename: 'remoteEntry.js',
exposes: {
'./Widget': './src/components/Widget.tsx',
},
dts: {
generateTypes: {
// Emits @mf-types.zip + a folder alongside remoteEntry.js
compilerInstance: 'tsc',
},
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
};
On the host, point the same plugin at the remote so it downloads and unpacks those declarations into a generated @mf-types directory at build time:
// host/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
remote_app: 'remote_app@https://remotes.example.com/remoteEntry.js',
},
dts: {
consumeTypes: {
remoteTypeUrls: {
// Optional explicit override; otherwise derived from the remote URL.
remote_app: 'https://remotes.example.com/@mf-types.zip',
},
},
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
};
Reference the generated folder from your host tsconfig.json so the compiler resolves remote_app/* to the downloaded declarations:
{
"compilerOptions": {
"paths": {
"*": ["./@mf-types/*"]
}
}
}
After a build, npx tsc --noEmit now reads the real WidgetProps and the bogus count prop in Step 1 fails to compile. The tradeoff: the type bundle is only as fresh as the last remote build that was published — see troubleshooting for stale dts.
A few practical notes for Option A. The dts plugin runs tsc over the exposed entry points only, so keep your exposes pointed at files with clean, exported public types rather than deep internal modules — anything reachable from the exposed surface gets pulled in. If your remote is on Vite, the equivalent plugin (@module-federation/vite) emits a compatible bundle, which means a Vite remote and a Webpack host can still share types. And because consumption happens during the host build, the generated @mf-types folder should be git-ignored and treated as a build artifact, never committed.
Step 3 (Option B) — Publish a shared types package #
When you want the contract to be reviewable, versioned, and toolchain-agnostic, extract the exposed component’s public types into a small package that both apps depend on. This is the same discipline as backward-compatible remote API contracts: the type is the contract.
Create a workspace package containing only declarations — no runtime code:
// packages/contracts/src/widget.ts
import type { ComponentType } from 'react';
export interface WidgetProps {
/** Title rendered in the widget header. */
title: string;
/** Selected item id, controlled by the host. */
selectedId?: string;
onSelect?: (id: string) => void;
}
export type WidgetComponent = ComponentType<WidgetProps>;
{
"name": "@scope/contracts",
"version": "1.4.0",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": { "build": "tsc -p tsconfig.build.json" }
}
The remote imports the interface to type its own export, guaranteeing the implementation matches the published shape:
// remote/src/components/Widget.tsx
import type { WidgetProps } from '@scope/contracts';
export default function Widget({ title, selectedId, onSelect }: WidgetProps) {
return <section aria-label={title}>{/* ... */}</section>;
}
The host uses the same package to type the federated import via a declaration shim (Step 4). Because both sides depend on @scope/contracts@^1.4.0, a breaking change forces a version bump that both teams see in their lockfiles — which is exactly the signal contract testing between frontend teams is built to enforce at runtime.
Step 4 — Write the declare module shim #
A runtime import like remote_app/Widget has no file backing it, so the host needs an ambient declaration that maps the federation specifier to the shared contract. Keep this in a typed .d.ts file in the host.
// host/src/remotes.d.ts
declare module 'remote_app/Widget' {
import type { WidgetComponent } from '@scope/contracts';
const Widget: WidgetComponent;
export default Widget;
}
Avoid the lazy wildcard declare module 'remote_app/*' that resolves everything to any — it hides exactly the errors you are trying to catch. Write one precise shim per exposed module and let unknown specifiers fail loudly. With Option A’s generated @mf-types, you can skip this step entirely; with Option B, the shim is what binds the package types to the runtime specifier.
Step 5 — Wire a CI step to pull and verify types #
Whichever option you choose, automate freshness so a developer never builds against a stale contract. For Option A, fetch the latest dts bundle before type-checking; for Option B, install the pinned package and let tsc enforce it.
#!/usr/bin/env bash
# host/scripts/sync-remote-types.sh (runs in postinstall or CI before tsc)
set -euo pipefail
# Option A: pull the freshest declarations the remote published.
curl -fSL "https://remotes.example.com/@mf-types.zip" -o /tmp/mf-types.zip
rm -rf ./@mf-types && unzip -q /tmp/mf-types.zip -d ./@mf-types
# Gate the build on type safety so drift fails the pipeline, not production.
npx tsc --noEmit
{
"scripts": {
"postinstall": "bash ./scripts/sync-remote-types.sh || true",
"typecheck": "bash ./scripts/sync-remote-types.sh"
}
}
Run typecheck as a required CI gate (drop the || true there) so a remote change that breaks the contract turns the host pipeline red instead of shipping a runtime mismatch. The postinstall hook keeps local developers reasonably fresh without blocking installs, while the CI typecheck job is the hard gate. Pin the package version for Option B in the same job and let Renovate or Dependabot propose bumps when the remote releases a new contract — the bump PR becomes a visible, reviewable record of every contract change, with the diff showing precisely which props were added or removed.
For teams running many remotes, loop the fetch over a manifest of remote URLs rather than hardcoding one. Keep the version of each pulled bundle in a small JSON lockfile so the host build is reproducible and you can diff which remote contract changed between two host builds.
Verification #
Confirm type safety is actually in force, not just configured:
- Compiler:
npx tsc --noEmitpasses, and the deliberately wrong<Widget count={42} />from Step 1 now reportsTS2322: Property 'count' does not exist on type 'WidgetProps'. - Editor: Hover over the import —
Widgetresolves toWidgetComponent, notany. Autocomplete inside<Widgetliststitle,selectedId, andonSelect. Go-to-definition jumps into@mf-typesor@scope/contracts. - CI log: The sync step prints the downloaded bundle size and
tscexits0, proving the gate ran against fresh declarations.
$ npm run typecheck
> sync-remote-types.sh
remote_app types: 14.2 kB unpacked
$ tsc --noEmit ✓ no errors
Troubleshooting #
Stale dts after a remote change (symptom: host types lag behind the live remote).
Diagnosis: the host built against a cached @mf-types.zip from a previous remote deploy, so new props are invisible and removed props still type-check. Fix: make the remote publish its dts bundle as part of every deploy and have the host re-fetch it on each CI run (Step 5) rather than caching it across builds. For Option B, the lockfile pins the version — bump @scope/contracts and reinstall.
Cannot find module 'remote_app/Widget' (symptom: TS2307 even after setup).
Diagnosis: the host tsconfig paths does not point at @mf-types, or the declare module shim filename is not included by tsconfig. Fix: confirm the .d.ts is inside an include glob, that paths maps "*": ["./@mf-types/*"], and that the federation remotes key (remote_app) exactly matches the module specifier prefix.
Version drift between runtime and types (symptom: types compile but the prop is undefined at runtime).
Diagnosis: the host’s declarations describe a newer or older remote than the remoteEntry.js actually loaded — common when the CDN serves an immutable older build. Fix: tie the dts version to the runtime version by serving both from the same hashed release path, and assert the loaded remote’s version in a smoke test. Aligning the type version with the deployed versioning strategy for the remote keeps the two in lockstep.
Wildcard shim swallows errors (symptom: bad props never fail).
Diagnosis: a leftover declare module 'remote_app/*' resolves every import to any. Fix: delete the wildcard and declare each exposed module explicitly, or rely on the generated @mf-types folder.