Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explicit Attribute Type #1

Open
dgp1130 opened this issue Mar 12, 2023 · 0 comments
Open

Explicit Attribute Type #1

dgp1130 opened this issue Mar 12, 2023 · 0 comments
Labels
enhancement New feature or request

Comments

@dgp1130
Copy link
Owner

dgp1130 commented Mar 12, 2023

For SSR use cases, it is useful to have a concrete type representing a component's supported attributes. Tools like JSX can use this to provide full type inference and validation. Currently, this is done by using the JSX.IntrinsicElements namespace, which can be extended with custom types like so:

declare module 'preact' {
    namespace JSX {
        interface IntrinsicElements {
            'my-component': { foo: string, children: ComponentChildren };
        }
    }
}

Annoyingly this sometimes needs to include ComponentChildren. However there is no clear "source of truth" for a component's attributes. Ideally this would be exported from the type based on usage. Yet, in a typical component it is the presence of getAttribute() which implies that an attribute is supported with some type constraints. Ideally this would be explicitly defined somehow. I think there is an opportunity here in HydroActive to design the APIs such that we can infer and export the attributes for a component. One strawman API for this would be:

export interface Props { /* ... */ }
export interface Attrs { 'counter-id': number };

export const MyComponent = component<Props, Attrs>('my-component', ($) => {
    console.log($.attr('counter-id', Number)); // Type checked. `Number` aligns with `number`.
    // Shorthand for:
    // $.read(':host', Number, attr('counter-id'));

    console.log($.attr('counter-id', String)); // Type check error. `String` does _not_ align with `number`.
    console.log($.attr('counter-id', (x) => x + 1)); // `x` is inferred to be type `number`? Maybe it should be `string` since attributes are always strings?
});

It would be doubly cool if we could export the attribute types to a well-known interface like HTMLElementTagNameMap. That way JSX and other rendering tools could validate attributes like so:

declare global {
    interface HTMLElementTagNameAttrMap {
        'my-component': Attrs;
    }
}

Then JSX could automatically set JSX.IntrinsicAttributes from that well-known type and the following would type check:

console.log(<MyComponent counterId={1234} />);
console.log(<MyComponent counterId={'test'} />); // Type error.

Ultimately this isn't really "inferring" the attribute type. Fundamentally in TypeScript we can only really infer types from values, meaning that if you $.read(':host', Number, attr('counter-id')), we can infer that the return type is number from the input Number constructor. But we can't infer values from types (converting number to Number), as that is "type-based emit" which a lot of tools struggle to support for various reasons.

A consequence of this is that we can't really "infer" complex types like string | number without a complex DSL based on JS primitives. We could use something like Zod as this DSL, but I don't think I want to force that dependency. Instead, I'm thinking we can "encourage" explicit attribute types by forcing users to define the interface and then type checking the primitive. For example, we can validate that Number is compatible with number | string during type check. And by pushing the user towards writing this explicit attribute type, we could make that available through a global or other means and then connect it with tools like JSX. Another thing to think about is that $.read() of a number | string value is kind of annoying to use right now, given that it parses and validates the input. You'd basically need a try-catch to actually support such a contract.

Unfortunately, I don't think we can declaration merge JSX.IntrinsicAttributes with a mapped type from HTMLElementTagNameAttrMap, so I suspect this is something JSX will need to support directly. They don't even seem to support HTMLElementTagNameMap today, so I'm not sure how much faith I have in that. Lit SSR might be a better opportunity for integration. I'm also not 100% on how JSX handles rich types in this situation anyways and what would it really mean to have a class instance as an attribute? Serialize to JSON? Defer the serialization format, but always inputs/outputs the instance? I'm not sure.

We'd likely need a community protocol for HTMLElementTagNameAttrMap and/or support in TypeScript for the global. If so, then I think more direct support for an inferred $.attr type would go a long way towards making attribute types explicit and working more closely with these tools.

One possible contradiction here is that element attributes are kind of always strings? In a HydroActive context, we support parsing them into more complex values, but they are always strings (or I guess booleans based on this.hasAttribute()). We could say a component has an attribute type of a complex class, such as interface Attrs { user: User } but somehow we need to convert the real string value into a User. That's not done automatically, so $.attr('user', User) makes some sense (if we can define the semantics of how User gets invoked), but $.attr('user', (user: User) => user.name) doesn't. I think my takeaway from this is that the attribute type is actually the output of $.attr() converters, not the input. Maybe that's ok since any subsequent conversion should come after the initial User construction? IDK.

Regardless, there's probably still value in answering the question "Which attributes are supported by this component?", even if all of them are always strings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant