Module 25: Type-Safe Module Federation (Senior/Enterprise)
Chapter 25 • Advanced
Type-Safe Module Federation (Senior/Enterprise)
The Real Problem
Scenario: Products team changes CartItem interface. Checkout app breaks at runtime. TypeScript didn't catch it because remote types aren't available.
The Question: How do I maintain type safety across micro frontends when remotes can change and TypeScript can't see remote code?
This is type safety at scale.
The Production Reality
Type Safety Challenges
- Remote Types Unavailable - TypeScript can't see remote code
- Runtime Type Mismatches - Types don't match at runtime
- Version Changes - Remote types change, local types don't
- Contract Evolution - Types change, but need backward compatibility
- Shared Types - Types shared across apps
The Question: How do we solve each challenge?
Strategy 1: Shared Type Definitions Package
The Pattern
Create a shared types package. All apps consume it.
Step 1: Create Types Package
# Create types package
mkdir shared-types
cd shared-types
npm init -y
# Structure
shared-types/
├── src/
│ ├── cart.types.ts
│ ├── product.types.ts
│ ├── user.types.ts
│ └── index.ts
└── package.json
Step 2: Define Shared Types
// shared-types/src/cart.types.ts
export interface CartItem {
id: string
productId: string
name: string
price: number
quantity: number
version: '1.0.0'
}
export interface Cart {
items: CartItem[]
total: number
version: '1.0.0'
}
// shared-types/src/product.types.ts
export interface Product {
id: string
name: string
description: string
price: number
imageUrl: string
category: string
}
export interface ProductListResponse {
products: Product[]
total: number
page: number
}
// shared-types/src/index.ts
export * from './cart.types'
export * from './product.types'
export * from './user.types'
Step 3: Consume in Apps
// products-app/package.json
{
"dependencies": {
"@company/shared-types": "^1.0.0"
}
}
// products-app/src/app/cart/cart.service.ts
import { CartItem, Cart } from '@company/shared-types'
@Injectable({ providedIn: 'root' })
export class CartService {
private items: CartItem[] = []
addItem(item: CartItem) {
this.items.push(item)
}
getCart(): Cart {
return {
items: this.items,
total: this.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
version: '1.0.0'
}
}
}
// checkout-app/src/app/cart/cart.service.ts
import { CartItem, Cart } from '@company/shared-types'
@Injectable({ providedIn: 'root' })
export class CheckoutCartService {
private items: CartItem[] = []
loadCart(cart: Cart) {
// Type-safe: TypeScript knows Cart structure
this.items = cart.items
}
}
Strategy 2: Type Definitions for Remotes
The Pattern
Generate type definitions for remote modules.
Step 1: Define Remote Module Types
// host-app/src/types/remotes.d.ts
declare module 'products/ProductsModule' {
import { Type } from '@angular/core'
export class ProductsModule {}
export class ProductsComponent {}
export class ProductDetailComponent {}
}
declare module 'checkout/CheckoutModule' {
import { Type } from '@angular/core'
export class CheckoutModule {}
export class CheckoutComponent {}
export class CartComponent {}
}
Step 2: Use in Host
// host-app/src/app/app-routing.module.ts
import { ProductsModule } from 'products/ProductsModule' // Type-safe!
import { CheckoutModule } from 'checkout/CheckoutModule' // Type-safe!
const routes: Routes = [
{
path: 'products',
loadChildren: () => import('products/ProductsModule')
.then(m => m.ProductsModule) // TypeScript knows this exists
},
{
path: 'checkout',
loadChildren: () => import('checkout/CheckoutModule')
.then(m => m.CheckoutModule) // TypeScript knows this exists
}
]
Strategy 3: Runtime Type Validation
The Problem
Types match at compile time, but not at runtime.
Solution: Runtime Type Guards
// shared-types/src/type-guards.ts
import { CartItem } from './cart.types'
export function isCartItem(value: any): value is CartItem {
return (
typeof value === 'object' &&
value !== null &&
typeof value.id === 'string' &&
typeof value.productId === 'string' &&
typeof value.name === 'string' &&
typeof value.price === 'number' &&
typeof value.quantity === 'number' &&
value.version === '1.0.0'
)
}
export function isCart(value: any): value is Cart {
return (
typeof value === 'object' &&
value !== null &&
Array.isArray(value.items) &&
value.items.every(isCartItem) &&
typeof value.total === 'number' &&
value.version === '1.0.0'
)
}
Use in Code
// checkout-app/src/app/cart/cart.service.ts
loadCart(cart: unknown) {
if (isCart(cart)) {
// TypeScript knows cart is Cart type
this.items = cart.items
} else {
console.error('Invalid cart structure:', cart)
throw new Error('Invalid cart')
}
}
Strategy 4: Versioned Types
The Problem
CartItem changes. Old Checkout breaks.
Solution: Versioned Type Definitions
// shared-types/src/cart.types.ts
// Version 1.0.0
export interface CartItemV1 {
id: string
productId: string
name: string
price: number
quantity: number
version: '1.0.0'
}
// Version 2.0.0 (additive)
export interface CartItemV2 extends CartItemV1 {
discount?: number
appliedPromo?: string
version: '2.0.0'
}
// Union type for backward compatibility
export type CartItem = CartItemV1 | CartItemV2
Use with Type Guards
// checkout-app/src/app/cart/cart.service.ts
handleCartItem(item: CartItem) {
if (item.version === '1.0.0') {
// Handle v1
const v1Item = item as CartItemV1
// Use v1Item
} else if (item.version === '2.0.0') {
// Handle v2
const v2Item = item as CartItemV2
// Use v2Item.discount, v2Item.appliedPromo
}
}
Strategy 5: Type Generation from APIs
The Pattern
Generate TypeScript types from API schemas.
Step 1: API Schema (OpenAPI/Swagger)
# api-schema.yaml
components:
schemas:
CartItem:
type: object
properties:
id:
type: string
productId:
type: string
name:
type: string
price:
type: number
quantity:
type: number
required:
- id
- productId
- name
- price
- quantity
Step 2: Generate Types
# Install type generator
npm install --save-dev openapi-typescript
# Generate types
npx openapi-typescript api-schema.yaml -o src/types/api.d.ts
Step 3: Use Generated Types
// products-app/src/app/api/products.api.ts
import { CartItem } from '@/types/api'
@Injectable({ providedIn: 'root' })
export class ProductsApiService {
constructor(private http: HttpClient) {}
addToCart(item: CartItem): Observable<CartItem> {
return this.http.post<CartItem>('/api/cart', item)
}
}
Strategy 6: Contract Testing with Types
The Pattern
Test that types match at runtime.
Step 1: Type Contract Tests
// shared-types/src/contract-tests.ts
import { CartItem, isCartItem } from './cart.types'
describe('CartItem Contract', () => {
it('should validate CartItem structure', () => {
const validItem: CartItem = {
id: '1',
productId: '2',
name: 'Product',
price: 10,
quantity: 1,
version: '1.0.0'
}
expect(isCartItem(validItem)).toBe(true)
})
it('should reject invalid CartItem', () => {
const invalidItem = {
id: '1',
// Missing required fields
}
expect(isCartItem(invalidItem)).toBe(false)
})
})
Step 2: Integration Type Tests
// products-app/src/app/cart/cart.service.spec.ts
import { CartItem } from '@company/shared-types'
describe('CartService Type Safety', () => {
it('should accept CartItem type', () => {
const service = new CartService()
const item: CartItem = {
id: '1',
productId: '2',
name: 'Product',
price: 10,
quantity: 1,
version: '1.0.0'
}
// TypeScript ensures type safety
service.addItem(item)
expect(service.getItems()).toContain(item)
})
})
Strategy 7: Type-Safe Event Contracts
The Pattern
Type-safe event definitions.
Step 1: Define Event Types
// shared-types/src/events.types.ts
export interface EventContract<T = any> {
event: string
version: string
data: T
timestamp: number
}
export interface CartItemAddedEvent extends EventContract<{
itemId: string
productId: string
quantity: number
price: number
}> {
event: 'cart:item-added'
version: '1.0.0'
}
export interface CartUpdatedEvent extends EventContract<{
items: CartItem[]
total: number
}> {
event: 'cart:updated'
version: '1.0.0'
}
export type AppEvent = CartItemAddedEvent | CartUpdatedEvent
Step 2: Type-Safe Event Bus
// shared-types/src/event-bus.types.ts
export class TypedEventBus {
private subject = new Subject<AppEvent>()
emit<T extends AppEvent>(event: T): void {
this.subject.next(event)
}
on<T extends AppEvent['event']>(
eventName: T
): Observable<Extract<AppEvent, { event: T }>> {
return this.subject.pipe(
filter((e): e is Extract<AppEvent, { event: T }> =>
e.event === eventName
)
)
}
}
Step 3: Use Type-Safe Events
// products-app/src/app/cart/cart.service.ts
emitCartItemAdded(item: CartItem) {
const event: CartItemAddedEvent = {
event: 'cart:item-added',
version: '1.0.0',
data: {
itemId: item.id,
productId: item.productId,
quantity: item.quantity,
price: item.price
},
timestamp: Date.now()
}
this.eventBus.emit(event) // Type-safe!
}
// checkout-app/src/app/cart/cart.service.ts
constructor(private eventBus: TypedEventBus) {
// TypeScript knows the event structure
this.eventBus.on('cart:item-added').subscribe(event => {
// event is CartItemAddedEvent
// TypeScript knows event.data structure
const { itemId, productId, quantity, price } = event.data
})
}
Production Checklist
- [ ] Shared types package created
- [ ] Remote module types defined
- [ ] Runtime type guards implemented
- [ ] Versioned types for backward compatibility
- [ ] Type generation from APIs (if applicable)
- [ ] Contract tests for types
- [ ] Type-safe event contracts
- [ ] Documentation for type usage
Key Takeaways
- Shared types package - Single source of truth
- Runtime validation - Types don't guarantee runtime safety
- Versioned types - Backward compatibility
- Type generation - Keep types in sync with APIs
- Type-safe events - Contracts for communication
Remember: Type safety in micro frontends requires discipline and tooling.
Congratulations! You've completed the entire Micro Frontend Architecture course. You now understand how to build, test, secure, and maintain production-grade micro frontend systems at scale.
You've crossed the line from "I've used Module Federation" to "I've shipped and maintained it in production."