TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Allow top-level function calls and decorator applications to augment interfaces

Open justinfagnani opened this issue 1 year ago • 2 comments

Suggestion

Reduce repeated code for modules that augment global interface like HTMLElementTagNameMap by letting functions and decorators declare how they augment the interface.

🔍 Search Terms

  • HTMLElementTagNameMap

✅ Viability Checklist

My suggestion meets these guidelines:

  • [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Provide a way, similar to custom type guards and assertions, for a function or decorator to declare that it augments an interface.

lib.dom.ts has a number of map interfaces that are good practice for modules to extend to add their element and event names and types to the built-in APIs that use them. Extending those interfaces is extra boilerplate though, and many authors forget to do so. It would be great if libraries that register elements or declaratively advertise events could ensure that the interfaces are extended automatically.

// This allows us to say that only the global registry augments HTMLElementTagNameMap
declare interface GlobalCustomElementRegistry extends CustomElementRegistry {
  define<N extends string, C extends CustomElementConstructor>(
      name: N,
      constructor: C,
      options?: ElementDefinitionOptions): declares global {
          interface HTMLElementTagNameMap {
            [N]: C;
          }
        }
      };
}
declare var customElements: GlobalCustomElementRegistry;

Usage:

export class MyElement extends HTMLElement { /* ... */ }

// An unconditional call to the function would augment the interface
customElements.define('my-element', MyElement);

It would be great if this could work for decorators too:

export
@customElement('my-element')
class MyElement extends HTMLElement { /* ... */ }

📃 Motivating Example

See above

💻 Use Cases

Custom HTML elements, custom DOM events.

justinfagnani avatar Oct 27 '22 20:10 justinfagnani

This seems extraordinarily difficult to reason about in nontrivial cases, if not impossible. Consider something like

interface Foo { }
declare function makeFooHaveBar(m: { bar?: number }): declares global {
  interface Foo { bar: string }
}

declare const f: Foo;
makeFooHaveBar(f);
makeFooHaveBar(f);

Are both calls illegal, or neither? Just the second?

What if you write something like this?

interface Foo { m: string; }
declare function addPrefixedPropertiesToFoo<T, S>(arg: T, type: S): declares global {
  interface Foo { [K in keyof T as `x_${K}]: S }
}

declare const f: Foo;
addPrefixedProperties(f, 0);
addPrefixedProperties(f, true);
addPrefixedProperties(f, "");

What's the type of Foo in this program? Are these calls processed sequentially? What if several calls exist in various top-level files from which there's no graph-inferrable ordering?

RyanCavanaugh avatar Oct 27 '22 22:10 RyanCavanaugh

@RyanCavanaugh I understand. I knew this was a super long-shot of an idea, I just threw it out there in case someone on the team actually had a realistic way of doing something here, or is sparked an idea.

I do think there are desirable answers to you questions, though.

interface Foo { }
declare function makeFooHaveBar(m: { bar?: number }): declares global {
  interface Foo { bar: string }
}

declare const f: Foo;
makeFooHaveBar(f);
makeFooHaveBar(f);

Should (IMO) behave like:

interface Foo { }
declare function makeFooHaveBar(m: { bar?: number }): void;

declare const f: Foo;
makeFooHaveBar(f);
declare global {
  interface Foo { bar: string }
}
makeFooHaveBar(f);
declare global {
  interface Foo { bar: string }
}

Which would have an error on both calls.

This example:

interface Foo { m: string; }
declare function addPrefixedPropertiesToFoo<T, S>(arg: T, type: S): declares global {
  interface Foo { [K in keyof T as `x_${K}]: S }
}

declare const f: Foo;
addPrefixedProperties(f, 0);
addPrefixedProperties(f, true);
addPrefixedProperties(f, "");
declares global {
  interface Foo { [K in keyof T as `x_${K}]: S }
}

I would think would behave something like:

declare global {
  interface Foo { m: string; }
}
declare const f: Foo;
declare global {
  type Foo = {
    [K in keyof typeof f as `x_${K}`]: 0;
  }
}
// etc

which definitely has some circularity problems due to typeof f being Foo. Maybe that's illegal?

Anyway, again, I get this is a wild idea and probably very unrealistic. Feel free to close!

justinfagnani avatar Nov 03 '22 00:11 justinfagnani