Skip to content

feat: Allow @Listen and @Event decorators to accept constants/variables for event names #6360

@jttommy

Description

@jttommy

Prerequisites

Describe the Feature Request

Feature Request: Allow @Listen and @Event decorators to accept constants/variables for event names

Problem Description

Currently, the @Listen decorator in Stencil only accepts string literals for event names, while the @Event decorator's eventName property has similar limitations. This prevents developers from using constants or variables consistently across both decorators, forcing duplication of event name strings throughout the codebase and increasing the risk of typos.

Current Behavior

const EVENTS = { CUSTOM_EVENT: 'customEvent' } as const;

// ❌ @Listen doesn't work with constants - compilation error
@Listen(EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }

// ❌ @Event eventName doesn't work with constants in some cases
@Event({ eventName: EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter; // May not work consistently

// ✅ Only string literals work reliably
@Listen('customEvent')
handleCustomEvent() { ... }

@Event({ eventName: 'customEvent' }) customEvent: EventEmitter;

Desired Behavior

const EVENTS = { CUSTOM_EVENT: 'customEvent' } as const;

// ✅ Should work - using constants consistently
@Listen(EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }

@Event({ eventName: EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter;

// ✅ Should work - using variables
const eventName = 'customEvent';
@Listen(eventName)
handleCustomEvent() { ... }

@Event({ eventName: eventName }) customEvent: EventEmitter;

// ✅ Alternative - factory function approach
@Listen(() => EVENTS.CUSTOM_EVENT)
handleCustomEvent() { ... }

@Event({ eventName: () => EVENTS.CUSTOM_EVENT }) customEvent: EventEmitter;

Use Case & Motivation

1. Avoid Typos:

// Current approach - prone to typos
@Event({ eventName: 'userLoginSuccess' }) loginSuccess: EventEmitter;
@Listen('userLoginSucces') // Typo! Missing 's'
handleLogin() { ... }

// Desired approach - typo-safe
const EVENTS = { USER_LOGIN_SUCCESS: 'userLoginSuccess' } as const;
@Event({ eventName: EVENTS.USER_LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER_LOGIN_SUCCESS) // No typos possible
handleLogin() { ... }

2. Maintain DRY Principles:

// Single source of truth for event names
export const EVENTS = {
  USER: {
    LOGIN_SUCCESS: 'userLoginSuccess',
    LOGIN_FAILURE: 'userLoginFailure',
    LOGOUT: 'userLogout'
  },
  DATA: {
    LOADED: 'dataLoaded',
    ERROR: 'dataError',
    REFRESH: 'dataRefresh'
  }
} as const;

// Use consistently across components
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleLoginSuccess() { ... }

@Event({ eventName: EVENTS.DATA.LOADED }) dataLoaded: EventEmitter;
@Listen(EVENTS.DATA.LOADED) handleDataLoaded() { ... }

3. Better Refactoring:
When event names need to change, you only update them in one place instead of searching and replacing string literals throughout the codebase.

4. Cross-Component Consistency:

// Component A emits event
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;

// Component B listens to the same event
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleUserLogin() { ... }

// Component C also listens
@Listen(EVENTS.USER.LOGIN_SUCCESS) onUserLogin() { ... }

Proposed Solution

Allow both decorators to accept constants/variables:

1. @Listen decorator:

const eventName = 'myEvent';
@Listen(eventName)
@Listen(EVENTS.MY_EVENT)
@Listen(() => EVENTS.MY_EVENT) // factory function alternative

2. @Event decorator:

@Event({ eventName: eventName })
@Event({ eventName: EVENTS.MY_EVENT })
@Event({ eventName: () => EVENTS.MY_EVENT }) // factory function alternative

Enhanced Decorator Implementation Example

// Enhanced @Listen decorator implementation
function Listen(
  eventName: string | (() => string), 
  opts?: ListenOptions
): MethodDecorator {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Resolve the event name if it's a function
    const resolvedEventName = typeof eventName === 'function' ? eventName() : eventName;
    
    // Continue with existing Stencil logic using resolvedEventName
    // (This would integrate into Stencil's existing compilation process)
    return applyListenDecorator(target, propertyKey, descriptor, resolvedEventName, opts);
  };
}

// Enhanced @Event decorator implementation  
function Event(opts: EventOptions & { eventName?: string | (() => string) }): PropertyDecorator {
  return function(target: any, propertyKey: string) {
    // Resolve the eventName if it's a function
    const resolvedOpts = { ...opts };
    if (opts.eventName && typeof opts.eventName === 'function') {
      resolvedOpts.eventName = opts.eventName();
    }
    
    // Continue with existing Stencil logic using resolvedOpts
    return applyEventDecorator(target, propertyKey, resolvedOpts);
  };
}

// Usage - no syntax changes needed, just enhanced support
const EVENTS = { 
  MODAL_CLOSED: 'modalClosed',
  USER_LOGIN: 'userLogin' 
} as const;

@Component({ tag: 'my-component' })
export class MyComponent {
  // Now works with functions that return event names
  @Event({ eventName: () => EVENTS.MODAL_CLOSED, bubbles: true })
  modalClosed: EventEmitter;
  
  @Listen(() => EVENTS.USER_LOGIN, { target: 'window' })
  handleUserLogin(event: CustomEvent) { ... }
  
  // Still works with constants/variables
  @Listen(EVENTS.MODAL_CLOSED)
  handleModalClosed() { ... }
  
  @Event({ eventName: EVENTS.USER_LOGIN })
  userLogin: EventEmitter;
  
  // Still works with string literals
  @Listen('click')
  handleClick() { ... }
  
  @Event({ eventName: 'customEvent' })
  customEvent: EventEmitter;
}

Type Definitions:

// Updated type definitions
interface ListenOptions {
  target?: string;
  capture?: boolean;
  passive?: boolean;
}

interface EventOptions {
  eventName?: string | (() => string);
  composed?: boolean;
  cancelable?: boolean;
  bubbles?: boolean;
}

declare function Listen(
  eventName: string | (() => string), 
  opts?: ListenOptions
): MethodDecorator;

declare function Event(
  opts?: EventOptions
): PropertyDecorator;

This approach enhances the existing decorators to detect when a function is passed and resolve it during compilation, while maintaining backward compatibility with all existing usage patterns.

Implementation Considerations

The Stencil compiler would need to:

  1. Detect when non-string-literal values are passed to both decorators
  2. Resolve the actual string value during compilation for both @Listen and @Event({ eventName: ... })
  3. Handle imported constants from other modules
  4. Provide helpful error messages when values can't be statically resolved
  5. Ensure consistent behavior between both decorators

Benefits

  • Type Safety: Leverage TypeScript's type system for event names
  • DRY Principle: Single source of truth for event names across emitters and listeners
  • Refactor Safety: Change event names in one place, affects both emission and listening
  • IDE Support: Better autocomplete and refactoring tools for event names
  • Consistency: Both @Event and @Listen can use the same constant definitions
  • Cross-Component Safety: Guaranteed matching between event emitters and listeners

Real-World Example

// events.constants.ts
export const COMPONENT_EVENTS = {
  MODAL: {
    OPENED: 'modalOpened',
    CLOSED: 'modalClosed',
    CONFIRMED: 'modalConfirmed'
  },
  FORM: {
    SUBMITTED: 'formSubmitted',
    VALIDATED: 'formValidated',
    RESET: 'formReset'
  }
} as const;

// modal.component.ts
import { COMPONENT_EVENTS } from './events.constants';

@Component({ tag: 'my-modal' })
export class MyModal {
  @Event({ eventName: COMPONENT_EVENTS.MODAL.OPENED }) modalOpened: EventEmitter;
  @Event({ eventName: COMPONENT_EVENTS.MODAL.CLOSED }) modalClosed: EventEmitter;
}

// app.component.ts
import { COMPONENT_EVENTS } from './events.constants';

@Component({ tag: 'my-app' })
export class MyApp {
  @Listen(COMPONENT_EVENTS.MODAL.OPENED)
  handleModalOpened() { ... }
  
  @Listen(COMPONENT_EVENTS.MODAL.CLOSED) 
  handleModalClosed() { ... }
}

Current Workarounds

Developers currently must:

  1. Use string literals everywhere (prone to typos)
  2. Use constants inconsistently (constants for @Event, literals for @Listen)
  3. Add comments to link constants with string literals:
    // Emit COMPONENT_EVENTS.MODAL.OPENED
    @Event({ eventName: 'modalOpened' }) modalOpened: EventEmitter;
    
    // Listen to COMPONENT_EVENTS.MODAL.OPENED  
    @Listen('modalOpened') handleModalOpened() { ... }

This feature would eliminate these workarounds and provide a more robust, maintainable development experience for both event emission and listening.

Describe the Use Case

Use Case & Motivation

  1. Avoid Typos:
// Current approach - prone to typos
@Event({ eventName: 'userLoginSuccess' }) loginSuccess: EventEmitter;
@Listen('userLoginSucces') // Typo! Missing 's'
handleLogin() { ... }

// Desired approach - typo-safe
const EVENTS = { USER_LOGIN_SUCCESS: 'userLoginSuccess' } as const;
@Event({ eventName: EVENTS.USER_LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER_LOGIN_SUCCESS) // No typos possible
handleLogin() { ... }
  1. Maintain DRY Principles:
// Single source of truth for event names
export const EVENTS = {
  USER: {
    LOGIN_SUCCESS: 'userLoginSuccess',
    LOGIN_FAILURE: 'userLoginFailure',
    LOGOUT: 'userLogout'
  },
  DATA: {
    LOADED: 'dataLoaded',
    ERROR: 'dataError',
    REFRESH: 'dataRefresh'
  }
} as const;

// Use consistently across components
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleLoginSuccess() { ... }

@Event({ eventName: EVENTS.DATA.LOADED }) dataLoaded: EventEmitter;
@Listen(EVENTS.DATA.LOADED) handleDataLoaded() { ... }
  1. Better Refactoring:
    When event names need to change, you only update them in one place instead of searching and replacing string literals throughout the codebase.

  2. Cross-Component Consistency:

// Component A emits event
@Event({ eventName: EVENTS.USER.LOGIN_SUCCESS }) loginSuccess: EventEmitter;

// Component B listens to the same event
@Listen(EVENTS.USER.LOGIN_SUCCESS) handleUserLogin() { ... }

// Component C also listens
@Listen(EVENTS.USER.LOGIN_SUCCESS) onUserLogin() { ... }

Describe Preferred Solution

Proposed Solution
Allow both decorators to accept constants/variables:

  1. @Listen decorator:
const eventName = 'myEvent';
@Listen(eventName)
@Listen(EVENTS.MY_EVENT)
@Listen(() => EVENTS.MY_EVENT) // factory function alternative
  1. @Event decorator:
@Event({ eventName: eventName })
@Event({ eventName: EVENTS.MY_EVENT })
@Event({ eventName: () => EVENTS.MY_EVENT }) // factory function alternative

Enhanced Decorator Implementation Example

// Enhanced @Listen decorator implementation
function Listen(
  eventName: string | (() => string), 
  opts?: ListenOptions
): MethodDecorator {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Resolve the event name if it's a function
    const resolvedEventName = typeof eventName === 'function' ? eventName() : eventName;
    
    // Continue with existing Stencil logic using resolvedEventName
    // (This would integrate into Stencil's existing compilation process)
    return applyListenDecorator(target, propertyKey, descriptor, resolvedEventName, opts);
  };
}

// Enhanced @Event decorator implementation  
function Event(opts: EventOptions & { eventName?: string | (() => string) }): PropertyDecorator {
  return function(target: any, propertyKey: string) {
    // Resolve the eventName if it's a function
    const resolvedOpts = { ...opts };
    if (opts.eventName && typeof opts.eventName === 'function') {
      resolvedOpts.eventName = opts.eventName();
    }
    
    // Continue with existing Stencil logic using resolvedOpts
    return applyEventDecorator(target, propertyKey, resolvedOpts);
  };
}

// Usage - no syntax changes needed, just enhanced support
const EVENTS = { 
  MODAL_CLOSED: 'modalClosed',
  USER_LOGIN: 'userLogin' 
} as const;

@Component({ tag: 'my-component' })
export class MyComponent {
  // Now works with functions that return event names
  @Event({ eventName: () => EVENTS.MODAL_CLOSED, bubbles: true })
  modalClosed: EventEmitter;
  
  @Listen(() => EVENTS.USER_LOGIN, { target: 'window' })
  handleUserLogin(event: CustomEvent) { ... }
  
  // Still works with constants/variables
  @Listen(EVENTS.MODAL_CLOSED)
  handleModalClosed() { ... }
  
  @Event({ eventName: EVENTS.USER_LOGIN })
  userLogin: EventEmitter;
  
  // Still works with string literals
  @Listen('click')
  handleClick() { ... }
  
  @Event({ eventName: 'customEvent' })
  customEvent: EventEmitter;
}

Type Definitions:

// Updated type definitions
interface ListenOptions {
  target?: string;
  capture?: boolean;
  passive?: boolean;
}

interface EventOptions {
  eventName?: string | (() => string);
  composed?: boolean;
  cancelable?: boolean;
  bubbles?: boolean;
}

declare function Listen(
  eventName: string | (() => string), 
  opts?: ListenOptions
): MethodDecorator;

declare function Event(
  opts?: EventOptions
): PropertyDecorator;

This approach enhances the existing decorators to detect when a function is passed and resolve it during compilation, while maintaining backward compatibility with all existing usage patterns.

Describe Alternatives

No response

Related Code

No response

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions