Skip to content

Instantly share code, notes, and snippets.

@rommguy
Created August 2, 2025 12:53
Show Gist options
  • Select an option

  • Save rommguy/8b59cc854202a65be77933d2233b7002 to your computer and use it in GitHub Desktop.

Select an option

Save rommguy/8b59cc854202a65be77933d2233b7002 to your computer and use it in GitHub Desktop.

Managing global config in frontend monorepo

Exported on 8/2/2025 at 15:50:14 GMT+3 from Cursor (1.3.7)


User

I'd like to discuss a solution for an infrastructure challenge in this frontend monorepo. Each package contains functionality around a certain area in the appliaction. The skeleton of the app is called platform-app and it imports the other packages to render the correct content accoridng to the routs. It also renders providers that provide global context such as a global partner config that the packages can read through useConfig or usePartnerFeature and check certain config params. Another example for global context is the a11y and global messages currently defined in messages.json in the a11y package. The code in the packages use formatMessage utility from useMelioIntl hook to translate keys to text.

The problems start when we want to add some functionality to one of the packages, for example ap/payment-flows. The functionality needs to add new text keys and new configuration params to the partner config. It means the code change touches global packages like config and a11y, instead of only be focused in the package. This results in the need to run all tests in the entire project as a result of such a change.

I would like to get to a point when each domain package also contains the texts and config params relevant for that area, but somehow still serve a merged result globally without creating circular dependencies


Cursor

This is a great infrastructure challenge! You're dealing with the classic tension between modular ownership and centralized configuration. Let me first understand the current structure better, then I'll propose some solutions.

const messages = { ...globalMessages, ...localMessages } as const;

Perfect! I can see the challenge clearly now. You have a well-structured monorepo, but the current global approach for messages and config creates unwanted coupling. I can see that the AR domain package has already started solving this problem for messages. Let me propose a comprehensive solution that builds on that pattern and extends it to both messages and configuration.

Current State Analysis

Based on my exploration, here's what I found:

I18n System

  • Global messages in core/i18n/src/messages.json
  • AR domain already has a working solution: They filter global messages by prefix and merge with local messages:
// packages/ar/domain/src/i18n/index.tsx
const messages = { ...globalMessages, ...localMessages } as const;
const { AsyncLocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<Messages>(messages);

Config System

  • Large global MelioConfig type in core/provider/src/types.ts
  • Partners merge base config with overrides using merge() from lodash
  • Some packages like standard providers already use inheritance patterns (as seen in PR #18967)

Proposed Solution: Package Registry Pattern

I propose implementing a Package Registry Pattern that allows each domain package to define its own messages and config while maintaining the current consumer APIs. Here's the approach:

1. Package-Level Structure

Each domain package would have its own i18n and config:

packages/ap/payment-flows/
├── src/
│   ├── i18n/
│   │   ├── messages.json          # Package-specific messages
│   │   └── index.ts               # Export messages
│   ├── config/
│   │   ├── config-schema.ts       # TypeScript types for config
│   │   ├── default-config.ts      # Default values
│   │   └── index.ts               # Export config
│   └── index.ts                   # Export everything

2. Registry System

Create a registry system that collects configurations from packages:

// core/provider/src/package-registry.ts
interface PackageContribution {
  messages?: Record<string, string>;
  configSchema?: object;
  defaultConfig?: object;
}

class PackageRegistry {
  private packages: Map<string, PackageContribution> = new Map();
  
  register(packageName: string, contribution: PackageContribution) {
    this.packages.set(packageName, contribution);
  }
  
  getMergedMessages(): Record<string, string> {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => ({ ...acc, ...pkg.messages }), {});
  }
  
  getMergedConfig(): object {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => mergeWith(acc, pkg.defaultConfig, mergeConfigCustomizer), {});
  }
}

export const packageRegistry = new PackageRegistry();

3. Auto-Registration Pattern

Each package would self-register on import:

// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

// Self-register this package's contributions
packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';
export * from './hooks';

4. Enhanced Provider Integration

Modify the existing providers to consume from the registry:

// core/provider/src/enhanced-providers.ts
import { packageRegistry } from './package-registry';
import { messages as coreMessages } from '@melio/platform-i18n';

export const createEnhancedMelioProvider = () => {
  // Merge core messages with package messages
  const allMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  };
  
  // Merge core config with package configs  
  const baseConfig = mergeWith(
    {},
    defaultGlobalConfig,
    packageRegistry.getMergedConfig(),
    mergeConfigCustomizer
  );
  
  return { allMessages, baseConfig };
};

5. Backward Compatibility

Keep the existing APIs working by enhancing them:

// The existing useConfig and useMelioIntl hooks remain unchanged
// but now pull from the enhanced merged configurations

Implementation Strategy

Phase 1: Infrastructure Setup

  1. Create the package registry system
  2. Enhance the provider to use the registry
  3. Maintain backward compatibility with existing global messages/config

Phase 2: Package Migration

  1. Start with one AP package (e.g., payment-flows) as a pilot
  2. Move relevant messages and config from global to package-level
  3. Update the package to self-register
  4. Verify tests run only for the affected package

Phase 3: Scale and Optimize

  1. Migrate other packages gradually
  2. Add build-time optimizations (static analysis, bundling)
  3. Remove unused global messages/config

Example: AP Payment Flows Package

Here's how packages/ap/payment-flows would look:

// packages/ap/payment-flows/src/i18n/messages.json
{
  "ap.paymentFlows.newFeature.title": "New Payment Feature",
  "ap.paymentFlows.newFeature.description": "Enhanced payment processing"
}

// packages/ap/payment-flows/src/config/default-config.ts
export const defaultConfig = {
  paymentFlows: {
    newFeature: {
      enabled: true,
      maxRetries: 3,
      timeoutMs: 30000
    }
  }
};

// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';

Benefits

  1. Isolated Testing: Changes to ap/payment-flows only run tests for that package
  2. Clear Ownership: Each package owns its messages and config
  3. No Circular Dependencies: Packages register with core, not vice versa
  4. Backward Compatibility: Existing useConfig and useMelioIntl APIs unchanged
  5. Incremental Migration: Can migrate packages one by one
  6. Type Safety: Each package can define strict TypeScript types for its config

Considerations

  1. Bundle Size: Need to ensure unused package contributions are tree-shaken
  2. Load Order: Registration must happen before providers are created
  3. Development Experience: May need dev tools to visualize merged config/messages
  4. Naming Conventions: Need consistent prefixing (e.g., ap.paymentFlows.*)

Would you like me to start implementing this solution? I'd recommend beginning with the infrastructure setup and then migrating one AP package as a proof of concept.

// packages/ar/domain/src/i18n/index.tsx
const messages = { ...globalMessages, ...localMessages } as const;
const { AsyncLocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<Messages>(messages);
packages/ap/payment-flows/
├── src/
│   ├── i18n/
│   │   ├── messages.json          # Package-specific messages
│   │   └── index.ts               # Export messages
│   ├── config/
│   │   ├── config-schema.ts       # TypeScript types for config
│   │   ├── default-config.ts      # Default values
│   │   └── index.ts               # Export config
│   └── index.ts                   # Export everything
// core/provider/src/package-registry.ts
interface PackageContribution {
  messages?: Record<string, string>;
  configSchema?: object;
  defaultConfig?: object;
}

class PackageRegistry {
  private packages: Map<string, PackageContribution> = new Map();
  
  register(packageName: string, contribution: PackageContribution) {
    this.packages.set(packageName, contribution);
  }
  
  getMergedMessages(): Record<string, string> {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => ({ ...acc, ...pkg.messages }), {});
  }
  
  getMergedConfig(): object {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => mergeWith(acc, pkg.defaultConfig, mergeConfigCustomizer), {});
  }
}

export const packageRegistry = new PackageRegistry();
// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

// Self-register this package's contributions
packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';
export * from './hooks';
// core/provider/src/enhanced-providers.ts
import { packageRegistry } from './package-registry';
import { messages as coreMessages } from '@melio/platform-i18n';

export const createEnhancedMelioProvider = () => {
  // Merge core messages with package messages
  const allMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  };
  
  // Merge core config with package configs  
  const baseConfig = mergeWith(
    {},
    defaultGlobalConfig,
    packageRegistry.getMergedConfig(),
    mergeConfigCustomizer
  );
  
  return { allMessages, baseConfig };
};
// The existing useConfig and useMelioIntl hooks remain unchanged
// but now pull from the enhanced merged configurations
// packages/ap/payment-flows/src/i18n/messages.json
{
  "ap.paymentFlows.newFeature.title": "New Payment Feature",
  "ap.paymentFlows.newFeature.description": "Enhanced payment processing"
}

// packages/ap/payment-flows/src/config/default-config.ts
export const defaultConfig = {
  paymentFlows: {
    newFeature: {
      enabled: true,
      maxRetries: 3,
      timeoutMs: 30000
    }
  }
};

// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';

User

I understand this approach, but there's something not clear to me - Is there a type somewhere that defines the result of the merges in the registry? Without sych a type, for example for the texts, how will TS know to validate I'm using a key that actually exists?


Cursor

Excellent question! You're absolutely right - without proper TypeScript types for the merged result, we'd lose the compile-time validation that currently ensures message keys exist. Let me look at the current type system and propose a solution.

Perfect question! You're absolutely right - the current system maintains type safety because createProvider<T> receives a strongly-typed messages object T, and the returned useMelioIntl hook enforces that formatMessage only accepts keyof T.

If we use a runtime registry, we'd lose this compile-time validation. Here's how we can solve this with Module Augmentation - a TypeScript feature that allows packages to contribute to a global type:

Solution: Module Augmentation + Registry

1. Global Registry Interface

// core/provider/src/package-registry.ts
export interface PackageMessages {
  // This will be augmented by each package
}

export interface PackageConfig {
  // This will be augmented by each package  
}

// Runtime registry (same as before)
class PackageRegistry {
  private packages: Map<string, PackageContribution> = new Map();
  
  getMergedMessages(): PackageMessages {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => ({ ...acc, ...pkg.messages }), {}) as PackageMessages;
  }
  
  getMergedConfig(): PackageConfig {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => mergeWith(acc, pkg.defaultConfig, mergeConfigCustomizer), {}) as PackageConfig;
  }
}

2. Package-Level Type Augmentation

Each package augments the global interfaces:

// packages/ap/payment-flows/src/i18n/messages.json
{
  "ap.paymentFlows.title": "Payment Flows",
  "ap.paymentFlows.newFeature.enabled": "New feature enabled"
}

// packages/ap/payment-flows/src/i18n/types.ts
import messages from './messages.json';

declare module '@melio/platform-provider/package-registry' {
  interface PackageMessages {
    'ap.paymentFlows.title': string;
    'ap.paymentFlows.newFeature.enabled': string;
  }
}

export type PaymentFlowsMessages = typeof messages;

3. Config Type Augmentation

// packages/ap/payment-flows/src/config/types.ts
declare module '@melio/platform-provider/package-registry' {
  interface PackageConfig {
    paymentFlows: {
      newFeature: {
        enabled: boolean;
        maxRetries: number;
        timeoutMs: number;
      };
    };
  }
}

export interface PaymentFlowsConfig {
  paymentFlows: {
    newFeature: {
      enabled: boolean;
      maxRetries: number; 
      timeoutMs: number;
    };
  };
}

4. Enhanced Provider with Full Type Safety

// core/provider/src/enhanced-provider.ts
import { packageRegistry, PackageMessages, PackageConfig } from './package-registry';
import { messages as coreMessages } from '@melio/platform-i18n';
import { MelioConfig } from './types';

// Merge core messages with package messages
type AllMessages = typeof coreMessages & PackageMessages;

// Merge core config with package config  
type AllConfig = MelioConfig & PackageConfig;

export const createEnhancedProvider = () => {
  const allMessages: AllMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  } as AllMessages;
  
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<AllMessages>(allMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};

// Enhanced useConfig hook with full type safety
export const useEnhancedConfig = (): AllConfig => {
  const baseConfig = useConfig();
  const packageConfig = packageRegistry.getMergedConfig();
  
  return mergeWith({}, baseConfig, packageConfig, mergeConfigCustomizer) as AllConfig;
};

5. Package Self-Registration with Types

// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

// Import for side effects (type augmentation)
import './i18n/types';
import './config/types';

// Runtime registration
packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';

Usage Example with Full Type Safety

// In any component
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this key exists
  const title = formatMessage('ap.paymentFlows.title');
  
  // ❌ TypeScript error - key doesn't exist
  const invalid = formatMessage('ap.paymentFlows.nonexistent');
  
  return <div>{title}</div>;
};

Alternative: Build-Time Code Generation

If module augmentation feels too complex, we could also use build-time code generation:

// Generated at build time: core/provider/src/generated-types.ts
export interface GeneratedMessages {
  // Auto-generated from all packages
  'ap.paymentFlows.title': string;
  'ap.activities.addBill.title': string;
  'ar.dashboard.invoices.title': string;
  // ... all message keys from all packages
}

export interface GeneratedConfig {
  // Auto-generated from all package configs
  paymentFlows: { /* ... */ };
  activities: { /* ... */ };
  // ...
}

Recommendation

I'd recommend the Module Augmentation approach because:

  1. Full Type Safety: TypeScript knows about all message keys and config properties at compile time
  2. No Build Step: No need for code generation tools
  3. Incremental: Each package can be migrated independently
  4. Familiar Pattern: Module augmentation is already used in many libraries (e.g., @types/react augments JSX)
  5. IDE Support: Full autocomplete and error checking

Would you like me to implement a working prototype of this approach? I could start with one AP package to demonstrate how the type safety works end-to-end.

// core/provider/src/package-registry.ts
export interface PackageMessages {
  // This will be augmented by each package
}

export interface PackageConfig {
  // This will be augmented by each package  
}

// Runtime registry (same as before)
class PackageRegistry {
  private packages: Map<string, PackageContribution> = new Map();
  
  getMergedMessages(): PackageMessages {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => ({ ...acc, ...pkg.messages }), {}) as PackageMessages;
  }
  
  getMergedConfig(): PackageConfig {
    return Array.from(this.packages.values())
      .reduce((acc, pkg) => mergeWith(acc, pkg.defaultConfig, mergeConfigCustomizer), {}) as PackageConfig;
  }
}
// packages/ap/payment-flows/src/i18n/messages.json
{
  "ap.paymentFlows.title": "Payment Flows",
  "ap.paymentFlows.newFeature.enabled": "New feature enabled"
}

// packages/ap/payment-flows/src/i18n/types.ts
import messages from './messages.json';

declare module '@melio/platform-provider/package-registry' {
  interface PackageMessages {
    'ap.paymentFlows.title': string;
    'ap.paymentFlows.newFeature.enabled': string;
  }
}

export type PaymentFlowsMessages = typeof messages;
// packages/ap/payment-flows/src/config/types.ts
declare module '@melio/platform-provider/package-registry' {
  interface PackageConfig {
    paymentFlows: {
      newFeature: {
        enabled: boolean;
        maxRetries: number;
        timeoutMs: number;
      };
    };
  }
}

export interface PaymentFlowsConfig {
  paymentFlows: {
    newFeature: {
      enabled: boolean;
      maxRetries: number; 
      timeoutMs: number;
    };
  };
}
// core/provider/src/enhanced-provider.ts
import { packageRegistry, PackageMessages, PackageConfig } from './package-registry';
import { messages as coreMessages } from '@melio/platform-i18n';
import { MelioConfig } from './types';

// Merge core messages with package messages
type AllMessages = typeof coreMessages & PackageMessages;

// Merge core config with package config  
type AllConfig = MelioConfig & PackageConfig;

export const createEnhancedProvider = () => {
  const allMessages: AllMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  } as AllMessages;
  
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<AllMessages>(allMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};

// Enhanced useConfig hook with full type safety
export const useEnhancedConfig = (): AllConfig => {
  const baseConfig = useConfig();
  const packageConfig = packageRegistry.getMergedConfig();
  
  return mergeWith({}, baseConfig, packageConfig, mergeConfigCustomizer) as AllConfig;
};
// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';
import { defaultConfig } from './config';

// Import for side effects (type augmentation)
import './i18n/types';
import './config/types';

// Runtime registration
packageRegistry.register('@melio/ap-payment-flows', {
  messages,
  defaultConfig
});

export * from './components';
// In any component
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this key exists
  const title = formatMessage('ap.paymentFlows.title');
  
  // ❌ TypeScript error - key doesn't exist
  const invalid = formatMessage('ap.paymentFlows.nonexistent');
  
  return <div>{title}</div>;
};
// Generated at build time: core/provider/src/generated-types.ts
export interface GeneratedMessages {
  // Auto-generated from all packages
  'ap.paymentFlows.title': string;
  'ap.activities.addBill.title': string;
  'ar.dashboard.invoices.title': string;
  // ... all message keys from all packages
}

export interface GeneratedConfig {
  // Auto-generated from all package configs
  paymentFlows: { /* ... */ };
  activities: { /* ... */ };
  // ...
}

User

Interesting. If each package declares module package-registry, will TS know at compile time the complete result type containing all the definitions from all the packages?


Cursor

Excellent question! This touches on a key limitation of module augmentation. The answer is "only if TypeScript can see all the augmentations" - which isn't guaranteed.

Let me explain the issue and show you better approaches:

You've identified the exact problem! Module augmentation only works if TypeScript can "see" all the augmentations during compilation. In a monorepo where packages compile independently, TypeScript won't know about package B's augmentations when compiling package A unless A imports B.

The Module Augmentation Problem

// packages/ap/payment-flows/src/types.ts
declare module '@melio/platform-provider' {
  interface PackageMessages {
    'ap.paymentFlows.title': string;  // ❌ Only visible if this package is imported
  }
}

// packages/ap/activities/src/SomeComponent.tsx  
import { useMelioIntl } from '@melio/platform-provider';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ❌ TypeScript error - it doesn't know about ap.paymentFlows.title
  // because ap/activities doesn't import ap/payment-flows
  return formatMessage('ap.paymentFlows.title');  
};

Better Solution: Explicit Type Registry

Looking at your monorepo structure and the existing pattern of centralizing types (like @melio/platform-types), here's a more reliable approach:

1. Central Type Registry Package

Create a new package that explicitly aggregates all package types:

// packages/shared/package-registry/src/types.ts

// Import all package message types explicitly
import type { PaymentFlowsMessages } from '@melio/ap-payment-flows/types';
import type { ActivitiesMessages } from '@melio/ap-activities/types'; 
import type { VendorMessages } from '@melio/ap-vendor-activities/types';
import type { ARDashboardMessages } from '@melio/ar-dashboard/types';

// Import all package config types
import type { PaymentFlowsConfig } from '@melio/ap-payment-flows/config';
import type { ActivitiesConfig } from '@melio/ap-activities/config';

// Explicitly merge all types
export type AllPackageMessages = 
  & PaymentFlowsMessages 
  & ActivitiesMessages 
  & VendorMessages 
  & ARDashboardMessages;

export type AllPackageConfigs = 
  & PaymentFlowsConfig 
  & ActivitiesConfig;
  
// Export the final merged types
export type EnhancedMessages = typeof coreMessages & AllPackageMessages;
export type EnhancedConfig = MelioConfig & AllPackageConfigs;

2. Package-Level Type Exports

Each package exports its own types without augmentation:

// packages/ap/payment-flows/src/types.ts (no module augmentation!)
import messages from './messages.json';

export type PaymentFlowsMessages = typeof messages;

export interface PaymentFlowsConfig {
  paymentFlows: {
    newFeature: {
      enabled: boolean;
      maxRetries: number;
    };
  };
}

3. Enhanced Provider with Explicit Types

// core/provider/src/enhanced-provider.ts
import { EnhancedMessages, EnhancedConfig } from '@melio/package-registry/types';
import { packageRegistry } from './package-registry';

export const createEnhancedProvider = () => {
  const allMessages = packageRegistry.getMergedMessages() as EnhancedMessages;
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<EnhancedMessages>(allMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};

export const useEnhancedConfig = (): EnhancedConfig => {
  // Implementation
};

4. Usage with Full Type Safety

// Any component can now use this with full type safety
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this exists because it's in EnhancedMessages
  const title = formatMessage('ap.paymentFlows.title');
  
  // ❌ TypeScript error - key doesn't exist  
  const invalid = formatMessage('nonexistent.key');
  
  return <div>{title}</div>;
};

Even Better: Build-Time Code Generation

Since you already have build tools, the most robust solution is build-time type generation:

// devtools/build-scripts/generate-package-types.js
const fs = require('fs');
const path = require('path');

// Scan all packages for messages.json and config types
const packages = scanPackages();

// Generate the aggregated types file
const generatedTypes = `
// Auto-generated - do not edit manually
import { messages as coreMessages } from '@melio/platform-i18n';
import { MelioConfig } from '@melio/platform-provider';

${packages.map(pkg => `import type { ${pkg.messagesType} } from '${pkg.name}/types';`).join('\n')}
${packages.map(pkg => `import type { ${pkg.configType} } from '${pkg.name}/config';`).join('\n')}

export type AllMessages = typeof coreMessages & ${packages.map(p => p.messagesType).join(' & ')};
export type AllConfig = MelioConfig & ${packages.map(p => p.configType).join(' & ')};
`;

fs.writeFileSync('packages/shared/package-registry/src/generated-types.ts', generatedTypes);

My Recommendation

Given your monorepo setup, I'd go with the Explicit Type Registry approach because:

  1. Reliable: No dependency on TypeScript's module resolution quirks
  2. Explicit: Clear dependencies between packages
  3. Familiar: Follows the existing pattern of @melio/platform-types
  4. Incremental: Can migrate packages one by one
  5. IDE-Friendly: Full autocomplete and error checking

The trade-off is that you need to manually update the central registry when adding new packages, but you could automate this with a simple script or even a lint rule.

Would you like me to prototype this solution? I could create the package registry structure and show how it would work with one AP package.

// packages/ap/payment-flows/src/types.ts
declare module '@melio/platform-provider' {
  interface PackageMessages {
    'ap.paymentFlows.title': string;  // ❌ Only visible if this package is imported
  }
}

// packages/ap/activities/src/SomeComponent.tsx  
import { useMelioIntl } from '@melio/platform-provider';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ❌ TypeScript error - it doesn't know about ap.paymentFlows.title
  // because ap/activities doesn't import ap/payment-flows
  return formatMessage('ap.paymentFlows.title');  
};
// packages/shared/package-registry/src/types.ts

// Import all package message types explicitly
import type { PaymentFlowsMessages } from '@melio/ap-payment-flows/types';
import type { ActivitiesMessages } from '@melio/ap-activities/types'; 
import type { VendorMessages } from '@melio/ap-vendor-activities/types';
import type { ARDashboardMessages } from '@melio/ar-dashboard/types';

// Import all package config types
import type { PaymentFlowsConfig } from '@melio/ap-payment-flows/config';
import type { ActivitiesConfig } from '@melio/ap-activities/config';

// Explicitly merge all types
export type AllPackageMessages = 
  & PaymentFlowsMessages 
  & ActivitiesMessages 
  & VendorMessages 
  & ARDashboardMessages;

export type AllPackageConfigs = 
  & PaymentFlowsConfig 
  & ActivitiesConfig;
  
// Export the final merged types
export type EnhancedMessages = typeof coreMessages & AllPackageMessages;
export type EnhancedConfig = MelioConfig & AllPackageConfigs;
// packages/ap/payment-flows/src/types.ts (no module augmentation!)
import messages from './messages.json';

export type PaymentFlowsMessages = typeof messages;

export interface PaymentFlowsConfig {
  paymentFlows: {
    newFeature: {
      enabled: boolean;
      maxRetries: number;
    };
  };
}
// core/provider/src/enhanced-provider.ts
import { EnhancedMessages, EnhancedConfig } from '@melio/package-registry/types';
import { packageRegistry } from './package-registry';

export const createEnhancedProvider = () => {
  const allMessages = packageRegistry.getMergedMessages() as EnhancedMessages;
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = createProvider<EnhancedMessages>(allMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};

export const useEnhancedConfig = (): EnhancedConfig => {
  // Implementation
};
// Any component can now use this with full type safety
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this exists because it's in EnhancedMessages
  const title = formatMessage('ap.paymentFlows.title');
  
  // ❌ TypeScript error - key doesn't exist  
  const invalid = formatMessage('nonexistent.key');
  
  return <div>{title}</div>;
};
// devtools/build-scripts/generate-package-types.js
const fs = require('fs');
const path = require('path');

// Scan all packages for messages.json and config types
const packages = scanPackages();

// Generate the aggregated types file
const generatedTypes = `
// Auto-generated - do not edit manually
import { messages as coreMessages } from '@melio/platform-i18n';
import { MelioConfig } from '@melio/platform-provider';

${packages.map(pkg => `import type { ${pkg.messagesType} } from '${pkg.name}/types';`).join('\n')}
${packages.map(pkg => `import type { ${pkg.configType} } from '${pkg.name}/config';`).join('\n')}

export type AllMessages = typeof coreMessages & ${packages.map(p => p.messagesType).join(' & ')};
export type AllConfig = MelioConfig & ${packages.map(p => p.configType).join(' & ')};
`;

fs.writeFileSync('packages/shared/package-registry/src/generated-types.ts', generatedTypes);

User

if we export from the new package that aggregates the types, and import if in the config package, I believe we are creating a ciruclar dependency


Cursor

// packages/ar/domain/src/i18n/index.tsx
const messages = { ...globalMessages, ...localMessages } as const;

Excellent catch! You're absolutely right - that would create a circular dependency:

@melio/ap-payment-flows 
  ↓ (imports enhanced provider)
@melio/platform-provider 
  ↓ (imports aggregated types)  
@melio/package-registry 
  ↓ (imports package types)
@melio/ap-payment-flows  ← CIRCULAR!

This is exactly why module augmentation is tempting (it avoids this), but as we discussed, it's unreliable in monorepos.

Solution: Build-Time Code Generation

The cleanest approach is to generate the aggregated types at build time, eliminating runtime circular dependencies:

1. Build-Time Type Scanner

// devtools/build-scripts/generate-registry-types.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');

function scanPackageMessages() {
  const messageFiles = glob.sync('packages/*/src/i18n/messages.json');
  const packages = [];
  
  messageFiles.forEach(file => {
    const packagePath = path.dirname(path.dirname(path.dirname(file)));
    const packageJson = require(path.join(packagePath, 'package.json'));
    const messages = require(path.resolve(file));
    
    packages.push({
      name: packageJson.name,
      messages: Object.keys(messages),
      path: file
    });
  });
  
  return packages;
}

function generateTypes() {
  const packages = scanPackageMessages();
  
  const messageKeys = packages.flatMap(pkg => pkg.messages);
  
  const generatedContent = `
// Auto-generated by build script - DO NOT EDIT
// Generated from: ${packages.map(p => p.path).join(', ')}

import { messages as coreMessages } from '@melio/platform-i18n';

export interface GeneratedPackageMessages {
${messageKeys.map(key => `  '${key}': string;`).join('\n')}
}

export type AllMessages = typeof coreMessages & GeneratedPackageMessages;

// Re-export for convenience
export { coreMessages };
`;

  fs.writeFileSync(
    'core/provider/src/generated-registry-types.ts', 
    generatedContent
  );
}

generateTypes();

2. Enhanced Provider Uses Generated Types

// core/provider/src/enhanced-provider.ts
import { createProvider } from '@melio/i18n-tools';
import { AllMessages, coreMessages } from './generated-registry-types';
import { packageRegistry } from './package-registry';

export const createEnhancedProvider = () => {
  // Runtime: merge actual message objects
  const runtimeMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  };
  
  // TypeScript: use the generated type for compile-time safety
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = 
    createProvider<AllMessages>(runtimeMessages as AllMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};

3. Packages Use Enhanced Provider (No Circular Dependency)

// packages/ap/payment-flows/src/components/SomeComponent.tsx
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this exists from generated types
  const title = formatMessage('ap.paymentFlows.newFeature.title');
  
  return <div>{title}</div>;
};

4. Package Registration (Runtime Only)

// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';

// Only runtime registration - no type dependencies
packageRegistry.register('@melio/ap-payment-flows', { messages });

export * from './components';

Integration with Build Process

Add to your build scripts:

// package.json
{
  "scripts": {
    "prebuild": "node devtools/build-scripts/generate-registry-types.js",
    "predev": "node devtools/build-scripts/generate-registry-types.js",
    "types:generate": "node devtools/build-scripts/generate-registry-types.js"
  }
}

Advanced: Watch Mode for Development

// devtools/build-scripts/watch-registry-types.js
const chokidar = require('chokidar');

chokidar
  .watch('packages/*/src/i18n/messages.json')
  .on('change', () => {
    console.log('Message files changed, regenerating types...');
    require('./generate-registry-types.js');
  });

Benefits of This Approach

  1. No Circular Dependencies: Build-time generation breaks the cycle
  2. Full Type Safety: TypeScript knows all message keys at compile time
  3. Zero Runtime Cost: Type generation happens at build time
  4. Automatic: Scans all packages automatically
  5. IDE Support: Full autocomplete and error checking
  6. Incremental: Can add packages without manual type updates

Handling Config Types Similarly

For configuration, you could scan for config schema files:

// In the same build script
function scanPackageConfigs() {
  const configFiles = glob.sync('packages/*/src/config/schema.ts');
  // Parse TypeScript AST to extract interface definitions
  // Generate combined config type
}

This approach is similar to how tools like GraphQL code generators work - they scan your schema files and generate TypeScript types at build time, providing full type safety without runtime circular dependencies.

Would you like me to implement a working prototype of this build-time generation approach?

@melio/ap-payment-flows 
  ↓ (imports enhanced provider)
@melio/platform-provider 
  ↓ (imports aggregated types)  
@melio/package-registry 
  ↓ (imports package types)
@melio/ap-payment-flows  ← CIRCULAR!
// devtools/build-scripts/generate-registry-types.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');

function scanPackageMessages() {
  const messageFiles = glob.sync('packages/*/src/i18n/messages.json');
  const packages = [];
  
  messageFiles.forEach(file => {
    const packagePath = path.dirname(path.dirname(path.dirname(file)));
    const packageJson = require(path.join(packagePath, 'package.json'));
    const messages = require(path.resolve(file));
    
    packages.push({
      name: packageJson.name,
      messages: Object.keys(messages),
      path: file
    });
  });
  
  return packages;
}

function generateTypes() {
  const packages = scanPackageMessages();
  
  const messageKeys = packages.flatMap(pkg => pkg.messages);
  
  const generatedContent = `
// Auto-generated by build script - DO NOT EDIT
// Generated from: ${packages.map(p => p.path).join(', ')}

import { messages as coreMessages } from '@melio/platform-i18n';

export interface GeneratedPackageMessages {
${messageKeys.map(key => `  '${key}': string;`).join('\n')}
}

export type AllMessages = typeof coreMessages & GeneratedPackageMessages;

// Re-export for convenience
export { coreMessages };
`;

  fs.writeFileSync(
    'core/provider/src/generated-registry-types.ts', 
    generatedContent
  );
}

generateTypes();
// core/provider/src/enhanced-provider.ts
import { createProvider } from '@melio/i18n-tools';
import { AllMessages, coreMessages } from './generated-registry-types';
import { packageRegistry } from './package-registry';

export const createEnhancedProvider = () => {
  // Runtime: merge actual message objects
  const runtimeMessages = {
    ...coreMessages,
    ...packageRegistry.getMergedMessages()
  };
  
  // TypeScript: use the generated type for compile-time safety
  const { LocalizationProvider, useMelioIntl, FormattedMessage } = 
    createProvider<AllMessages>(runtimeMessages as AllMessages);
  
  return { LocalizationProvider, useMelioIntl, FormattedMessage };
};
// packages/ap/payment-flows/src/components/SomeComponent.tsx
import { useMelioIntl } from '@melio/platform-provider/enhanced';

const MyComponent = () => {
  const { formatMessage } = useMelioIntl();
  
  // ✅ TypeScript knows this exists from generated types
  const title = formatMessage('ap.paymentFlows.newFeature.title');
  
  return <div>{title}</div>;
};
// packages/ap/payment-flows/src/index.ts
import { packageRegistry } from '@melio/platform-provider/package-registry';
import messages from './i18n/messages.json';

// Only runtime registration - no type dependencies
packageRegistry.register('@melio/ap-payment-flows', { messages });

export * from './components';
// package.json
{
  "scripts": {
    "prebuild": "node devtools/build-scripts/generate-registry-types.js",
    "predev": "node devtools/build-scripts/generate-registry-types.js",
    "types:generate": "node devtools/build-scripts/generate-registry-types.js"
  }
}
// devtools/build-scripts/watch-registry-types.js
const chokidar = require('chokidar');

chokidar
  .watch('packages/*/src/i18n/messages.json')
  .on('change', () => {
    console.log('Message files changed, regenerating types...');
    require('./generate-registry-types.js');
  });
// In the same build script
function scanPackageConfigs() {
  const configFiles = glob.sync('packages/*/src/config/schema.ts');
  // Parse TypeScript AST to extract interface definitions
  // Generate combined config type
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment