typed-ember / glint

TypeScript powered tooling for Glimmer templates
https://typed-ember.gitbook.io/glint
MIT License
109 stars 51 forks source link

Assignability of union types for `Args` #591

Open chriskrycho opened 1 year ago

chriskrycho commented 1 year ago

Here's an interesting limitation of Glint's handling of arguments we ran into last week. I'm leaving this here so we can hopefully (eventually!) chase it down. A colleague was attempting to accurately capture the semantics of a component which renders different tags dynamically.[^element-helper] He wanted to capture that the component in question applies ...attributes to an HTMLListElement if it receives @tagName='li' and to an HTMLDivElement if it receives @tagName='div' or nothing, so we wrote a signature like this:

interface DynamicTagBlocks {
  Blocks: {
    default: [];
  };
}

interface WithLi extends DynamicTagBlocks {
  Args: {
    tagName: 'li';
  };
  Element: HTMLLIElement;
}

interface WithDiv extends DynamicTagBlocks {
  Args: {
    tagName?: 'div' | undefined;
  };
  Element: HTMLDivElement;
}

type DynamicTagSignature = WithLi | WithDiv;

const DynamicTag: TOC<Sig> = <template>
  {{#if (eq @tagName 'li')}}
    <li> ...attributes>{{yield}}</li>
  {{else}}
    <div ...attributes>{{yield}}</div>
  {{/if}}
</template>;

const Caller: TOC<Sig> = <template>
  <Example @tagName={{@tagName}}>
    {{yield}}
  </Example>
</template>;

function eq(a: unknown, b: unknown): boolean {
  return a === b;
}

This itself seems to work fine, but if you have another component which also has this same union-typed signature—so it can be similarly configurable and pass down ...attributes, while adding its own behavior; a fairly standard pattern, in other words—, things go badly:

const Caller: TOC<DynamicTagSignature> = <template>
  <DynamicTag @tagName={{@tagName}} ...attributes />
</template>;

This produces the failure:

Argument of type '[{ [NamedArgs]: true; tagName: "li" | "div" | undefined; }]' is not assignable to parameter of type '[named: { tagName: "li"; } & NamedArgsMarker] | [named?: ({ tagName?: "div" | undefined; } & NamedArgsMarker) | undefined]'.
  Type '[{ [NamedArgs]: true; tagName: "li" | "div" | undefined; }]' is not assignable to type '[named?: ({ tagName?: "div" | undefined; } & NamedArgsMarker) | undefined]'.
    Type '{ [NamedArgs]: true; tagName: "li" | "div" | undefined; }' is not assignable to type '{ tagName?: "div" | undefined; } & NamedArgsMarker'.
      Type '{ [NamedArgs]: true; tagName: "li" | "div" | undefined; }' is not assignable to type '{ tagName?: "div" | undefined; }'.
        Types of property 'tagName' are incompatible.
          Type '"li" | "div" | undefined' is not assignable to type '"div" | undefined'.

By contrast, if we do what users would expect to be equivalent in a TS context:

function dynamicTag(args: DynamicTagSignature['Args']) {
  if (args.tagName === 'li') {
    console.log('li');
  } else {
    console.log('div');
  }
}

function caller(args: DynamicTagSignature['Args']) {
  dynamicTag({ tagName: args.tagName });
}

This works as expected, including all the things we would expect with excess property checks (see this playground). I also confirmed (though have since misplaced the code) that this also holds when adding a NamedArgsMarker the same way Glint does.

Glint's output:

CleanShot 2023-06-20 at 10 34 46@2x

An initial hypothesis, based on deconstructing the Glint intermediate representation, was that the return type from the resolve() overload we are using here—

export declare function resolve<Args extends unknown[], Instance extends InvokableInstance>(
  item: (abstract new (...args: Args) => Instance) | null | undefined
): (...args: Parameters<Instance[typeof Invoke]>) => ReturnType<Instance[typeof Invoke]>;

—ends up distributing over the union while the naïve version I wrote in TS does not. But this is not the case: you can write a “dumb” version of resolve() which just uses Parameters to make sure it keeps the distributivity, and it still works:

declare function resolve<A extends any[], R, F extends (...args: A) => R>(
  item: F | null | undefined
): (...args: Parameters<F>) => R;

resolve(dynamicTag)({ tagName: 'li' })

function caller(args: DynamicTagSignature['Args']) {
  resolve(dynamicTag)({ tagName: args.tagName });
}

My next best guess is that it is somehow about the interaction between that and getting the types from Γ.args instead of "directly", but honestly? 🤷🏻‍♂️

[^element-helper]: This was more or less a workaround for a bug in ember-element-helper, but as is our habit, we are preserving the semantics of existing code when converting to TypeScript rather than changing behavior and adding types simultaneously.