Skip to content

Migrate from enums to string union types for better cross-package compatibility #883

@georgewrmarshall

Description

@georgewrmarshall

Problem

TypeScript enums create significant migration friction when updating packages across the MetaMask ecosystem. Even when enum values are identical, TypeScript treats different enum types as incompatible due to nominal typing.

Real-World Example: MenuItem Migration

PR #39810 demonstrates the complexity of migrating the MenuItem component from @metamask/component-library to @metamask/design-system-react. Because both packages use enums for the same conceptual values, MenuItem is forced to support dual prop systems:

type MenuItemProps = {
  // Legacy props from component-library
  iconNameLegacy?: IconNameLegacy;   // Old enum
  iconColorLegacy?: IconColorLegacy;
  textVariantLegacy?: TextVariantLegacy;
  
  // New props from @metamask/design-system-react
  iconName?: IconName;                // New enum (same values!)
  iconSize?: IconSize;
  textVariant?: TextVariant;
};

// Runtime logic to choose which system to use
const useNewSystem = iconName || textVariant;

The problem: Even though IconNameLegacy.Trash and IconName.Trash both resolve to 'Trash', TypeScript considers them incompatible types. This forces complex workarounds:

  1. Duplicate props for legacy and new systems
  2. Runtime branching to determine which system to use
  3. Conditional rendering based on which props are provided
  4. Every consumer component must be updated to use the new prop names

This pattern is repeated across hundreds of call sites:

- <MenuItem iconName={IconName.Trash} />
+ <MenuItem iconNameLegacy={IconName.Trash} />

Impact on Consumer Codebases

Extension and mobile teams face similar challenges:

  • They may define their own enums that match design system values
  • Wrapper components must support multiple enum types
  • Gradual migration becomes difficult without dual type support

Proposed Solution: String Union Types

Replace TypeScript enums with string union types + const objects for value references:

// Instead of:
export enum ButtonIconSize {
  Sm = 'sm',
  Md = 'md',
  Lg = 'lg',
}

// Use:
export type ButtonIconSize = 'sm' | 'md' | 'lg';
export const ButtonIconSize = {
  Sm: 'sm',
  Md: 'md',
  Lg: 'lg',
} as const;

Benefits

1. Backwards Compatibility
Both type and value exports work identically to enums:

// Type usage (works)
const props: { size: ButtonIconSize } = { size: 'md' };

// Value reference (works)
<ButtonIcon size={ButtonIconSize.Md} />

2. Cross-Package Compatibility
String unions use structural typing, so they accept values from any source:

// Extension's own enum
enum ButtonIconSizeEnum {
  Sm = 'sm',
  Md = 'md',
  Lg = 'lg',
}

// All of these work with design-system-react's string union type:
<ButtonIcon size="sm" />                        // ✅ String literal
<ButtonIcon size={ButtonIconSize.Sm} />         // ✅ Design system const
<ButtonIcon size={ButtonIconSizeEnum.Sm} />     // ✅ Extension enum

No dual props needed! One prop type works with old enums, new const objects, and string literals.

3. Simplified Migrations
The MenuItem migration would have been much simpler:

type MenuItemProps = {
  iconName: IconName;  // Single prop accepts all sources!
};

// Works with old enums automatically:
<MenuItem iconName={IconNameLegacy.Trash} />   // 
<MenuItem iconName="Trash" />                   // ✅

4. Better Developer Experience

  • String literals work without imports: <Button size="md" />
  • Autocomplete still works with const object references
  • Type errors are clearer (shows string values, not enum member names)
  • Easier to document (values are self-explanatory strings)

Migration Strategy

Phase 1: Update Design System Exports

For each enum in @metamask/design-system-react:

  1. Export both string union type and const object with same name
  2. Remove the enum
  3. Update internal component prop types

Example:

// Before
export enum ButtonIconSize {
  Sm = 'sm',
  Md = 'md',
  Lg = 'lg',
}

// After
export type ButtonIconSize = 'sm' | 'md' | 'lg';
export const ButtonIconSize = {
  Sm: 'sm',
  Md: 'md',
  Lg: 'lg',
} as const;

Phase 2: Consumer Adoption

Extension and mobile teams can migrate gradually:

// Mark old enums as deprecated but keep them
/** @deprecated Use string literals or ButtonIconSize const object */
enum ButtonIconSizeEnum {
  Sm = 'sm',
  Md = 'md',
  Lg = 'lg',
}

// Both work during transition
<ButtonIcon size={ButtonIconSizeEnum.Sm} />  // Still works
<ButtonIcon size="sm" />                      // Gradually adopt

No breaking changes - old enum values work with new string union types!

Phase 3: Cleanup

After migration is complete, consumer codebases can:

  • Remove deprecated enums
  • Adopt string literals for cleaner code
  • Or keep using design system const objects

Enums to Migrate

Common enum types that should be converted:

  • ButtonIconSize, ButtonSize
  • IconName, IconSize, IconColor
  • TextVariant, TextColor, TextAlign
  • BadgeStatus
  • AvatarSize, AvatarVariant
  • BorderRadius, BackgroundColor
  • Any other enums in component prop types

References

Success Criteria

  • All enums in @metamask/design-system-react migrated to string unions
  • Extension and mobile teams can use old enums during transition
  • No breaking changes to existing component APIs
  • Documentation updated with migration guide
  • TypeScript strict mode compatibility maintained

Metadata

Metadata

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