vuejs / language-tools

⚡ High-performance Vue language tooling based-on Volar.js
https://marketplace.visualstudio.com/items?itemName=Vue.volar
MIT License
5.87k stars 400 forks source link

Strongly-typed inheritAttrs #4882

Closed jods4 closed 1 month ago

jods4 commented 1 month ago

What problem does this feature solve?

Currently it's difficult to automatically strongly-type components that wrap other components (aka HOC). This is a common complaint, e.g. this RFC links to many issues: https://github.com/vuejs/rfcs/discussions/479

If a component Out wraps a component In, the goal would be that tooling shows strong typing for In props on Out, even though Out doesn't declare them.

What does the proposed solution look like?

My idea is not intrusive and would benefit everyone with zero code change.

SFC compiler and runtimes remain unchanged. They work fine and there's no need to modify them. What happens is that in Out, undeclared props end up in attrs, and with inheritAttrs: true they are copied to In props.

Tooling is modified to change the declared props type of Out. Let's say components have props of type PO and PI respectively. When a component has inheritAttrs: true and a single root component in template, instead of declaring Out<PO>, it's type is declared as Out<Inherit<PI, PO>>.

The secret sauce is a mapped type Inherit that copies properties that are not re-declared by PO. Here's my take on it:

type Inherit<In, Out> = Out & {
  [p in keyof In as Exclude<p, keyof Out>]: In[p]
}

These apparent props are a bit of lie, but the typing actually matches the runtime behaviour so I think it's a simple solution to a common issue.

jods4 commented 1 month ago

Potential work-around for community

As this is a type-only solution with no runtime change, there might be a way to apply this in user-land until Vue fixes this issue. It's slightly ugly, but it seems works.

[!WARNING] This is new and not battle-tested. Use with caution. That said, this work-around is only type trickery. JS code doesn't change and it cannot create bugs, only compilation errors.

The idea is simple: re-export the Vue component with an altered typing.

Let's say you have two components wrapper.vue and inside.vue. You'd like to pretend wrapper.vue has props of inside.vue:

You'll need to create a wrapper.ts file that re-exports wrapper.vue. It's the same component, but has different types. Instead of importing wrapper.vue, you'll import wrapper from "./wrapper.ts".

The typing is where this becomes quite ugly, as Vue types are very complex. I believe the solution below works for script setup components / types, it may need to be altered for Options API or functional components.

You'll need a way to extract Props types. Package vue-component-type-helpers has ComponentsProps<T>. It's really short, you can as well copy it into your project if you prefer:

type ComponentProps<T> =
    T extends new (...args: any) => { $props: infer P; } ? NonNullable<P> :
    T extends (props: infer P, ...args: any) => any ? P :
    {};

Then you'll need types to hide props from B that are present in A:

type Inherits<A, B> = A & Omit<B, keyof A>;

type FallthroughProps<A, B> = Inherits<ComponentProps<A>, ComponentProps<B>>;

Finally you can create a fake wrapper type. This is the part that may only work if A is a script setup component. Other magic types can probably be cooked for other components.

type HOC<A extends (...args: any) => any, B> = (props: FallthroughProps<A, B>, ...args: any) => ReturnType<A>;

And here's the example from before:

// File: wrapper.ts, import this instead of wrapper.vue

import Wrapper from "wrapper.vue"
import type Inside from "inside.vue"

export default Wrapper as HOC<typeof Wrapper, typeof Inside>

This even works when Wrapper is generic, but genericity of Inside and its potential link to Wrapper is lost. Typing can be written for related generic components, but I don't know how to write it without doing the expansion manually. Here's an example with generic Inside<T> and Wrapper<T>, and the T generic type should be the same based on Wrapper template:

export const FallthroughWrapper = Wrapper as <T>(
  props: FallthroughProps<typeof Wrapper<T>, typeof Inside<T>>,
  ...args: any
) => ReturnType<typeof Wrapper<T>>;

This pattern can be adapted to more generic arguments and relationships between Wrapper and Inside.

jods4 commented 1 month ago

Well, I'm closing this issue as it seems I missed an update two months ago! https://github.com/vuejs/language-tools/pull/4103

It's sad it's an opt-in because of poor performance, as it's very useful, but it's better than nothing :) Hopefully the perf can be improved and become a default in the future!

I can't seem to get that setting to work, but that's another issue, though 😂