Typescript: keyof becoming union of string literal in emitted type definitions

Created on 18 Sep 2018  Β·  13Comments  Β·  Source: microsoft/TypeScript


TypeScript Version: 3.0.1


Search Terms: declaration emit keyof as union type

Code

We're seeing this problem in LitElement: https://github.com/Polymer/lit-element/blob/master/src/lib/decorators.ts#L43

export const customElement = (tagName: keyof HTMLElementTagNameMap) =>
    (clazz: Constructor<HTMLElement>) => {
      window.customElements.define(tagName, clazz);
      // Cast as any because TS doesn't recognize the return type as being a
      // subtype of the decorated class when clazz is typed as
      // `Constructor<HTMLElement>` for some reason. `Constructor<HTMLElement>`
      // is helpful to make sure the decorator is applied to elements however.
      return clazz as any;
    };

We do this so that users are forced to add their elements to the HTMLElementTagNameMap:

@customElement('my-element')
class MyElement extends HTMLElement {}

declare global {
  interface HTMLElementTagNameMap {
    'my-element': MyElement;
  }
}

Expected behavior:

The declaration for customElement is emitted as:

export declare const customElement: (tagName: keyof HTMLElementTagNameMap) => (clazz: Constructor<HTMLElement>) => any;

And the example user code has no errors.

Actual behavior:

This is the declaration emit for customElement (see https://unpkg.com/@polymer/[email protected]/lib/decorators.d.ts):

export declare const customElement: (tagName: "object" | "a" | "abbr" | "acronym" | "address" | "applet" | "area" | "article" | "aside" | "audio" | "b" | "base" | "basefont" | "bdo" | "big" | "blockquote" | "body" | "br" | "button" | "canvas" | "caption" | "center" | "cite" | "code" | "col" | "colgroup" | "data" | "datalist" | "dd" | "del" | "dfn" | "dir" | "div" | "dl" | "dt" | "em" | "embed" | "fieldset" | "figcaption" | "figure" | "font" | "footer" | "form" | "frame" | "frameset" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "head" | "header" | "hgroup" | "hr" | "html" | "i" | "iframe" | "img" | "input" | "ins" | "isindex" | "kbd" | "keygen" | "label" | "legend" | "li" | "link" | "listing" | "map" | "mark" | "marquee" | "menu" | "meta" | "meter" | "nav" | "nextid" | "nobr" | "noframes" | "noscript" | "ol" | "optgroup" | "option" | "output" | "p" | "param" | "picture" | "plaintext" | "pre" | "progress" | "q" | "rt" | "ruby" | "s" | "samp" | "script" | "section" | "select" | "slot" | "small" | "source" | "span" | "strike" | "strong" | "style" | "sub" | "sup" | "table" | "tbody" | "td" | "template" | "textarea" | "tfoot" | "th" | "thead" | "time" | "title" | "tr" | "track" | "tt" | "u" | "ul" | "var" | "video" | "wbr" | "xmp") => (clazz: Constructor<HTMLElement>) => any;

And the above user code has an error, because extending HTMLElementTagNameMap has no effect.

Playground Link: The Playground doesn't show declaration files.

Related Issues: This is exactly the issue, but it was closed and locked: https://github.com/Microsoft/TypeScript/issues/21445

In Discussion Suggestion

Most helpful comment

@epeli Don't know if it helps, but as mentioned in other issues #33117 and #17294 this issue does not impact function and class declarations, only function and class expressions. Maybe you could structure your code to use function/class declarations? It's hard to propose a more concrete solution without actual code.

@justinfagnani The code in the original ticket also works as expected with a function declaration, not sure you came across the workaround:

export type Constructor<T> = new()=> T

export function customElement(tagName: keyof HTMLElementTagNameMap){
    return (clazz: Constructor<HTMLElement>) => {
      window.customElements.define(tagName, clazz);
      // Cast as any because TS doesn't recognize the return type as being a
      // subtype of the decorated class when clazz is typed as
      // `Constructor<HTMLElement>` for some reason. `Constructor<HTMLElement>`
      // is helpful to make sure the decorator is applied to elements however.
      return clazz as any;
    }
};

Outputs in d.ts file:

export declare type Constructor<T> = new () => T;
export declare function customElement(tagName: keyof HTMLElementTagNameMap): (clazz: Constructor<HTMLElement>) => any;

All 13 comments

There's another issue assigned to me that is this which I had a stale PR for.

@DanielRosenwasser I see the "bug" label came and went. Is there any question that this is a bug?

Expanding the keyof operator seems entirely incompatible with declaration merging, and I'm not sure what we'd even do if this isn't fixed... maybe a post-compile fixup script?

Both behaviors are somewhat problematic, and there's a consistency problem here because we're still going to potentially emit expanded names in certain cases due to an inability to emit anything else (e.g. if you have a switch and exclude certain cases before returning)

Yikes. I still don't think expansion is the right solution even in those cases because it breaks declaration merging. I'd rather infer an overly broad type than an overly narrow one.

It sounds like expansion is only done because there are types that can be inferred that can't be expressed in the type syntax. Is subtraction the only one? (extends might be another)

This might be a reason to re-open the subtraction types issue, or to add syntax for all types.

We ran into the same problem: https://github.com/PolymerLabs/actor-helpers/pull/35/files/4f886d342f13f32c5aa7ca012bde52c71432667e#r236728831 Surprisingly, in the class definition we had before, we did not have this problem. However, when we moved this class into a function, the declaration file became incorrect.

Anyone figured out a workaround for this?

I'm releasing a module that depends on users augmenting module types and only just after doing initial release experiments I noticed this literal inlining of the keyof occurring when emitting the declarations which breaks my module.

Planning to do a code mode of some sorts for the emitted declaration files currently because I'm completely stuck with this atm but that's a very dirty solution...

@epeli Don't know if it helps, but as mentioned in other issues #33117 and #17294 this issue does not impact function and class declarations, only function and class expressions. Maybe you could structure your code to use function/class declarations? It's hard to propose a more concrete solution without actual code.

@justinfagnani The code in the original ticket also works as expected with a function declaration, not sure you came across the workaround:

export type Constructor<T> = new()=> T

export function customElement(tagName: keyof HTMLElementTagNameMap){
    return (clazz: Constructor<HTMLElement>) => {
      window.customElements.define(tagName, clazz);
      // Cast as any because TS doesn't recognize the return type as being a
      // subtype of the decorated class when clazz is typed as
      // `Constructor<HTMLElement>` for some reason. `Constructor<HTMLElement>`
      // is helpful to make sure the decorator is applied to elements however.
      return clazz as any;
    }
};

Outputs in d.ts file:

export declare type Constructor<T> = new () => T;
export declare function customElement(tagName: keyof HTMLElementTagNameMap): (clazz: Constructor<HTMLElement>) => any;

Oh, I didn't know that! But unfortunately the issue does seem to impact function declarations inside other functions.

Eg. very minimally my code is like this:

export interface SharedThings {
    foo: string;
    bar: string;
}

export function createMyThing() {
    function thing<T extends keyof SharedThings>(mode: T): T {
        return {} as T;
    }

    return thing;
}

and TypeScript emits this

export interface SharedThings {
    foo: string;
    bar: string;
}
export declare function createMyThing(): <T extends "foo" | "bar">(mode: T) => T;

In reality my code is bit more complicated though I think it's comes down to what's above.

But in more detail inside my module I have a generic createMyThing() in lib.ts:

export function createMyThing<Shared>() {
    function thing<T extends keyof Shared>(mode: T): T {
        return {} as T;
    }

    return {
        thing: thing,
    };
}

which is emitted correctly

export declare function createMyThing<Shared>(): {
    thing: <T extends keyof Shared>(mode: T) => T;
};

and in index.ts I do

export interface SharedThings {
    foo: string;
    bar: string;
}

export const MyThing = createMyThing<SharedThings>();

and that unfortunately emits to

export interface SharedThings {
    foo: string;
    bar: string;
}

export declare const MyThing: {
    thing: <T extends "foo" | "bar">(mode: T) => T;
};

@epeli You can still get the correct declaration emit if you add an interface. It does imply some type duplication, but at least the emitted code is what you want it to be:

export interface IMyThing<Shared>{
    thing<T extends keyof Shared>(mode: T): T
}
export function createMyThing<Shared>(): IMyThing<Shared> {
    function thing<T extends keyof Shared>(mode: T): T {
        return {} as T;
    }

    return {
        thing: thing,
    };
}

export interface SharedThings {
    foo: string;
    bar: string;
}

export const MyThing = createMyThing<SharedThings>();

in d.ts we get:

export declare const MyThing: IMyThing<SharedThings>;

That could work indeed! But only nearly for my usecase.

In my index.ts file I actually destructure the created object and export the functions individually

export const { thing } = createMyThing<SharedThings>();

and that emits again 🀦

export declare const thing: <T extends "foo" | "bar">(mode: T) => T;

Also doing

export const MyThing = createMyThing<SharedThings>();
export const thing = MyThing.thing;

Emits the same thing. MyThing exports correctly but thing is being inlined.

Hmm, at least I seem to be able to do

export const thing: IMyThing<SharedThings>["thing"] = MyThing.thing;

I has an interface declared by merging declaration files.

// module-a/index.d.ts
declare module 'my-module'{
  export default interface MyInterface {
    a: string
  }
}

// module-b/index.d.ts
declare module 'my-module'{
  export default interface MyInterface {
    b: number
  }
}

type Keys = keyof MyInterface

export function myFunc<T extends Keys>(k: Keys) {}

At emitting time, only some declaration files available that leads to somethings like:

export declare function myFunc(k:'a') // πŸ€¦β€β™€οΈ

I'm seeing this issue as well in a function that accesses keys of a type from a peer dependency.

import { SomethingTypes } from "peer-dependency";

export default {
    foo<S extends keyof SomethingTypes>(bar: MyType<S>) { }
}

becomes

    foo<S extends "key1" | "key2" | "key3"...

This is an issue because it means my package needs to be updated in tandem with the peer dependency.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

blendsdk picture blendsdk  Β·  3Comments

dlaberge picture dlaberge  Β·  3Comments

Roam-Cooper picture Roam-Cooper  Β·  3Comments

seanzer picture seanzer  Β·  3Comments

jbondc picture jbondc  Β·  3Comments