vhtml icon indicating copy to clipboard operation
vhtml copied to clipboard

How do I use this with TypeScript?

Open balupton opened this issue 6 years ago • 7 comments

I can figure out the:

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "h",
  },
}

But I can't figure out how to get the typings going for h and things like children,

Specifically the error I'm getting on any JSX element is:

JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.ts(7026)

balupton avatar May 05 '19 16:05 balupton

I think you're after a declaration like this:

declare namespace JSX {
  interface IntrinsicElements {
      [elemName: string]: any;
  }
}

See https://www.typescriptlang.org/docs/handbook/jsx.html#intrinsic-elements.

ithinkihaveacat avatar Jul 24 '19 21:07 ithinkihaveacat

Although the above works - if any of your code imports something that imports React (like storybook), those global JSX types will be included, and those will be prioritised over [elemName: string]: any;.

And anything that's imported explicitly (even through something else) cannot be ignored/excluded in the tsconfig.json :(

Hoping someone else has a solution for this :)

ThaNarie avatar Nov 27 '20 20:11 ThaNarie

@types/vhtml is now public (see https://github.com/developit/vhtml/issues/25#issuecomment-750644187). I think we can close this issue.

pastelmind avatar Dec 24 '20 00:12 pastelmind

The same issue @ThaNarie mentioned about global JSX occurs in the DefinitelyTyped version as well right? Perhaps an issue for that can be opened there for something like the preact types approach.

Either way I think this is resolved. Thanks @pastelmind

AndrewLeedham avatar Dec 24 '20 15:12 AndrewLeedham

@AndrewLeedham Probably. I suppose we could split the JSX.IntrinsicElements interface into a separate jsx.d.ts file. Then users who want vhtml JSX semantics would need to add this to the top of their code:

/// <reference types="vhtml/jsx" />

// This can also work
import {} from "vhtml/jsx";

@types/react seems to be using similar approach. See experimental.d.ts

Since I am unfamiliar with this trick, I am hesitant to work on it myself. Feel free to submit a PR to DefinitelyTyped.

pastelmind avatar Dec 25 '20 08:12 pastelmind

I've been experimenting with enabling children type-checking with TypeScript. I was unsuccessful.

It turns out TypeScript has some strict assumptions about how child components are passed around in JSX.

function MyComponent(props: { children: any }): JSX.Element {
  /* ... */
}

const noChild = <MyComponent/>;
const oneChild = <MyComponent><div>the child</div></MyComponent>;
const manyChildren = (
  <MyComponent>
    <div>child 1</div>
    <div>child 2</div>
    <div>child 3</div>
  </MyComponent>
);

When TypeScript examines the props of a function component, it assumes:

  1. noChild is given props.children === undefined
  2. oneChild is given props.children === <div>the child</div>
  3. manyChildren is given props.children === [ <div>child1</div>, <div>child2</div>, <div>child3</div> ]

However, vhtml actually does this:

  1. noChild is given props.children === []
  2. oneChild is given props.children === [ <div>the child</div> ]
  3. manyChildren is given props.children === [ <div>child1</div>, <div>child2</div>, <div>child3</div> ]

Only the last assumptions match.

Because of this, TypeScript will happily accept the following code:

const WantString = (props: { children: string }) => {
  // Should be fine, right?
  return props.children.toLowerCase();
}

// TypeScript: I think you're receiving props.children === "asdf"
// vhtml: Nope, I will pass props.children === ["asdf"] and your component will die trying to lowercase an array
const result = <WantString>{"asdf"}</WantString>

The only way of guaranteeing type safety is to type all children as any, which defeats the purpose of type checking in the first place.


I suspect that TypeScript's assumptions are largely based on how React.createElement() works. There are several solutions for this.

  1. Change vhtml to handle children like React does. Will be a semver-major change, and move away from Preact-like semantics.
  2. Ask TypeScript to handle JSX differently. Unlikely to work since the status quo works just fine for React.
  3. Write a new library (possibly in TypeScript) similar to vhtml, but better conformance to TypeScript.

Edit: Looks like Preact has got into this as well. See https://github.com/preactjs/preact/issues/1008 and https://github.com/preactjs/preact/pull/1116 where they hacked their way around the discrepancy between Preact's children handling behavior and TypeScript's assumptions.

pastelmind avatar Jan 07 '21 15:01 pastelmind

@types/vhtml 2.2.1 supports strict children type checks!

https://github.com/preactjs/preact/pull/1116#issuecomment-414004721 gave me a hint to look into JSX.LibraryManagedAttributes, an obscure feature implemented in TypeScript 3.0. I used it to transform function component types into shapes that TypeScript recognizes for JSX type checking.

Examples

For example, given the following component:

function Component(props: { children: string[] }): JSX.Element {
  /* ... */;
}

TypeScript will allow any number of children, as long as they all evaluate to a string:

<Component>Foo{"bar"}<div>baz</div></Component>; // OK
<Component>{1}</Component>; // Error!

Childless component

If you omit props.children, TypeScript will interpret it as a "childless component":

const NoChildren() => <div>No children!</div>;

let result1 = <NoChildren/>; // OK
let result2 = <NoChildren>This won't work</NoChildren>; // Compile error

Single-child component

If you use a tuple literal with exactly one element, TypeScript will check that too.

// vhtml will flatten and concatenate arrays in JSX, so this is fine
const OneChild(props: { children: [string] }) => <div>{props.children}</div>;

let result1 = <OneChild/>; // Compile error
let result2 = <OneChild>Yes</OneChild>; // OK
let result3 = <OneChild><div>1</div><div>2</div></OneChild>; // Compile error

Rejecting invalid children type

vhtml always passes props.children as an array. TypeScript can enforce this, too:

// props.children is not an array!
const BadComponent(props: { children: string }) => <div>{props.children}</div>;

let result1 = <BadComponent/>; // Compile error
let result2 = <BadComponent>Yes</BadComponent>; // Compile error
let result3 = <BadComponent><div>1</div><div>2</div></BadComponent>; // Compile error

All this is made possible by using a series of conditional checks. Should one need to support more type checks, all we need is to expand those checks.

Limitations

Unfortunately, these tricks aren't as flexible as I would like them to be. For example, you can't enforce type checks for N-length tuple types (N > 1)--TypeScript will simply treat them as arbitrary-length arrays

// I want exactly three children, in the order of boolean, string, number
const Imperfect(props: { children: [boolean, string, number]; }) => { /* ... */ };

// ...but TypeScript will treat the above as (boolean | string | number)[]
// so this compiles just fine:
<Imperfect>{true}</Imperfect>;

I haven't bothered to tackle this, but it seems we would need variadic tuples (introduced in TypeScript 4.0) to make it happen. Since TypeScript 3.x is still going strong, it would be prudent not to use bleeding edge features in our type definitions.

(If you can make it happen in TypeScript 3.x, please submit a PR to DefinitelyTyped! We would appreciate it very much.)

Extending intrinsic attributes

Another caveat: To ensure that plain HTML tags accept anything as children, they internally have a children: any property. However, this is incompatible with function component prop types, which require that children: any[]. This can become a problem when you want to extend the attributes of built-in tags:

// I want my component to act like a <div> with benefits.
// I know: I'll extend the built-in interface used for type checking <div>
type Props = JSX.IntrinsicAttributes['div'] & { someProp?: number };

const BetterDiv = (props: Props) => {
  const { children, ...rest } = props;
  return <div {...rest}>{ /* ... */ }</div>;
}

<BetterDiv someProp={12}>foo bar</BetterDiv>; // Compile error

To work around the issue, always define your own children prop:

type DivProps = JSX.IntrinsicAttributes['div'];
interface Props extends DivProps {
  children: any[];
  someProp?: number;
}

const BetterDiv = (props: Props) => {
  const { children, ...rest } = props;
  return <div {...rest}>{ /* ... */ }</div>;
}

<BetterDiv someProp={12}>foo bar</BetterDiv>; // OK

pastelmind avatar Jan 11 '21 06:01 pastelmind