Search code examples
typescriptdesign-patternsalpine.jsdefinitelytyped

Typescript: Use inferred type of object for use in Generic


The Context

I'm trying to use Alpine.js together with TypeScript. For that, I'm using the community-maintained typings package @types/alpinejs (GitHub) together with the re-useable components design pattern described here. Here is a simple example using the type AlpineComponent<T>:

// This is the type for alpine components from definitely typed
import { AlpineComponent } from "alpinejs";

/**
 * I have to declare the T-part of the AlpineComponent<T> beforehand,
 * to be able to use it later. Otherwise, all my custom properties
 * will have the type "any"
 */
type Component = AlpineComponent<{
  foo: string;
  greet: (to: string) => string;
  // The list goes on and on and on in real-world code
}>

export default (): Component => {
  return {
    foo: "foo", // type "string", as defined in the type `Component`
    bar: "bar", // inconveniently has the type "any", since I didn't declare it inside my type `Component`...will have to do it manually... 😩
    greet(to) {
      return `Hello ${to}!`;
    },
    /**
     * init() is being invoked automatically by Alpine.js when the component is mounted
     */
    async init() {
      console.log(this.greet("World")); // greet correctly has the return type "string"
      await this.$nextTick(); // this is a "magic" method from Alpine.js. It's defined in the type `AlpineComponent`.
    },
  }
}

As you can see in the above example, right now I first define my type Component = AlpineComponent{...} with all the properties I will be using. And then I have to type them again when actually building my component.

Downsides of my current approach

  1. Typing everything twice as I'm doing now is quite cumbersome and feels very conflated.
  2. On alt-click on any property, my IDE (VS Code) now always jumps to the definition in my custom type Component rather then to the implementation inside my actual code. But I'm actually always more interested in the implementation instead of the definition.

The actual Question

Is there a better way to organize this, with less repeated typing of properties? Maybe something like inferring the type of the object returned from my component for the dynamic part of AlpineComponent<T>?


Solution

  • Someone on GitHub came up with exactly what I was looking for:

    https://github.com/alpinejs/alpine/issues/2199#issuecomment-1809892127

    Using a helper function that augments the provided function with the Alpine.js internals works like a charm:

    utils.ts:

    import type { AlpineComponent } from 'alpinejs'
    
    export const defineComponent = <P, T>(fn: (params: P) => AlpineComponent<T>) => fn
    

    component.ts:

    import { defineComponent } from '~/utils/define-component'
    
    export default defineComponent(() => ({
      isOpen: false,
      init() {
        this.$watch('isOpen', (value) => {
          this.onIsOpenChange(value)
        })
      },
      onIsOpenChange(value: boolean) {
        console.log('isOpen changed to ', value)
      }
    }))
    

    Using that I can now both

    1. make use of Alpine.js internals without TypeScript errors
    2. have the implementation (custom functionality) infered from inside the component itself