blog.pshrmn

Let's start with a TypeScript module for a React Native component. There is an error with the code, can you spot it?

// MyComponent.tsx
import React from "react";
import { Text } from "react-native";

export default function MyComponent({ value }) {
  return (
    <div>
      <Text>{value}</Text>
    </div>
  );
}

The component creates a div element, but there is no such thing as a div in React Native. If you try to render this component, you would get an error. Invariant Violation: View config not found for name div. Make sure to start component names with a capital letter.

This is why we use TypeScript, right? Unfortunately TypeScript has no issue with this code.

React Types

React does not provide its own TypeScript type definitions, so the DefinitelyTyped 1 community has stepped up and written them. These types can be installed through @types/react 2.

npm install react @types/react

For the most part, React's types are great, but as demonstrated above, they are not perfect. Why does TypeScript think that a <div> is valid in a React Native component?

Host Components

Before we dive into React's type definitions, let's do a quick review of React's createElement function, which is what JSX gets compiled to.

The first argument passed to createElement is its type, which ends up as the type of the returned element.

const divEle = React.createElement("div"); // or <div />;
// divEle.type === "div"

const testEle = React.createElement(Test); // or <Test />;
// testEle.type === Test

React's behavior varies based on what an element's type is. When it is a string, React treats it as a host component, which are the native elements for a rendering environment. In a React DOM application, div and a are host components.

<div /> // div is a DOM host component
<Test /> // Test is a custom component (class or function)
<React.Portal /> // A special React component type

Host components are only relevant to their native environment. The DOM can create an HTML node for a div, but, as we saw above, React Native has no idea what to do with a div.

createElement's second argument is an object of props. HTML and SVG have rules about the valid attributes for each host component. A div and an anchor may both receive an id, but only the anchor should receive an href.

// yes
React.createElement("a", { id: "test" });
// yes
React.createElement("div", { id: "test" });
// yes
React.createElement("a", { href: "https://www.example.com" });
// no
React.createElement("div", { href: "https://bad.example.com" });

Intrinsic Elements

TypeScript's JSX type checking refers to host components as "intrinsic elements". In order to know which strings are valid host components, TypeScript checks for an IntrinsicElements interface on the global JSX namespace.

The keys of the IntrinsicElements interface are valid host components and their properties are interfaces of their respective attributes. If we define an interface for each host component, JSX validation will only allow the expected attributes to be passed as props.

declare global {
  namespace JSX {
    interface EmoteAttributes {
      face: string;
    }

    interface IntrinsicElements {
      emote: EmoteAttributes
    }
  }
}

<emote face=":)" /> // TypeScript is cool with this

This means that if we add all of the DOM host components (HTML/SVG) to JSX.IntrinsicElements, TypeScript will be able to validate them.

declare global {
  namespace JSX {
    interface HTMLAttributes {...}
    interface AnchorHTMLAttributes extends HTMLAttributes {
      href?: string;
    }

    interface IntrinsicElements {
      a: AnchorHTMLAttributes,
      div: HTMLAttributes
    }
  }
}

<a href="#" /> // okay
<div href="#" /> // error

@types/react

The core React API works everywhere, while environment specific code is left to the renderers (React DOM, React Native, etc.).

If you browse through the @types/react definitions, you might expect to only find definitions that are relevant everywhere React may be used. However, in addition to the type definitions for React's public API, @types/react contains hundreds of definitions for types that are only relevant to the DOM.

What are these DOM types doing here?

Let's go back to createElement, a core part of the React API.

In TypeScript, function definitions can be overloaded to support different argument types. This means that there can be separate definitions of createElement for string types, function types, etc.

The basic type definition for passing a string would look something like this:

interface Attributes {
  key?: Key
}

function createElement<P>(
  type: string,
  props?: Attributes & P,
  ...children: ReactNode[]
): ReactElement;

Defining type as a string allows any host component, valid or not, to be passed to createElement.

createElement("span") // okay
createElement("glorp") // also okay

This is different than JSX validation, which only allows the provided host components.

In order to implement this, TypeScript would need to know what all of the valid DOM elements are.

TypeScript's keyof syntax allows us to spread the keys of an interface as a type.

interface Things {
  one: number;
  two: number;
}

type Thing = keyof Things // "one" or "two"

An interface whose keys are valid HTML/SVG elements can be spread and passed as the type for createElement, which is what @types/react does. React.ReactHTML is an interface of HTML elements while React.ReactSVG is an interface of SVG elements.

declare namespace React {
  interface ReactHTML {
    a: ...,
    div: ...,
    // ...            
  }
}

Simply knowing the valid host components is not enough, we also need to know their allowed attributes so that the props can be validated.

declare namespace React {
  interface ReactHTML {
    a: ...,
    div: ...,
    // ...            
  }
}

The props type is also generic. To truly validate host components, their props should be restricted to their valid attributes. This means that each host component needs an interface of valid attributes. The @types/react definitions also includes these in the React namespace.

declare namespace React {
  interface AnchorHTMLAttributes<T> extends HTMLAttributes<T> {
      download?: any;
      href?: string;
      hrefLang?: string;
      media?: string;
      rel?: string;
      target?: string;
      type?: string;
  }
}

Some of these attributes are DOM events, which also end up in the React namespace.

declare namespace React {
  type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
}

The final result is that we get a createElement definition that looks like this:

function createElement<
  P extends HTMLAttributes<T>,
  T extends HTMLElement
>(
  type: keyof ReactHTML,
  props?: ClassAttributes<T> & P | null,
  ...children: ReactNode[]
): DetailedReactHTMLElement<P, T>;

In order to provide a better type definitions for React's API, the React namespace ends up being full of DOM definitions. This isn't ideal, but doesn't explain the original issue: why does TypeScript consider DOM host components to be valid for React Native?

Intrinsic React Elements

Remember that JSX.IntrinsicElements is an interface whose keys are host component types and whose properties are valid attribute interfaces. @types/react creates all of these host component attribute interfaces that JSX.IntrinsicElements needs. @types/react takes advantage of those to add the DOM elements to the global JSX.IntrinsicElements definition.

declare global {
  namespace JSX {
    interface IntrinsicElements {
      a: React.DetailedHTMLProps<
          React.AnchorHTMLAttributes<HTMLAnchorElement>,
          HTMLAnchorElement
        >,
      // ...
    }
  }
}

With that, TypeScript can validate JSX types and props.

<a invalidPropType="test" />
// Type '{ invalidPropType: string; }' is not assignable to type
// 'DetailedHTMLProps<
//    AnchorHTMLAttributes<HTMLAnchorElement>,
//    HTMLAnchorElement
// >'.

This is great; it makes the developer's life easier by catching bad element types and props. The issue is with where it exists.

The JSX elements that @types/react defines are only relevant in a DOM environment, but @types/react is used anywhere the react is imported. This means that TypeScript will think that DOM JSX is valid in any project that uses @types/react.

How can this be fixed? One possible solution is to move the DOM-specific definitions to somewhere we know that the DOM will be used: @types/react-dom.

@types/react-dom

@types/react-dom provides type definitions for the React DOM API. This includes the render and hydrate methods, along with a few other things related to rendering. Why not define the DOM types here as well?

One nice thing about TypeScript is that you can write ambient modules to extend an existing module. This means that @types/react-dom could extend the React namespace to add any DOM related definitions without actually changing the current type definitions 3.

* While the API doesn't change if both @types/react and @types/react-dom are installed, this would break DOM applications that don't have @types/react-dom installed. However, if you are writing DOM components, @types/react-dom should be installed.

With the DOM attribute interfaces moved to @types/react-dom, the JSX.IntrinsicElements should be moved as well. This change would mean that if you don't have @types/react-dom installed (e.g. in a React Native application), you don't have to worry about TypeScript validating DOM elements, solving the original issue!

// @types/react-dom/index.d.ts
          
export const render: Renderer;
export const hydrate: Renderer;
// ...

declare module 'react' {
  interface ReactHTML {
    // ...
  }

  type MouseEventHandler<T = Element> = EventHandler<MouseEvent<T>>;
}

declare global {
  namespace JSX {
    interface IntrinsicElements {
      a: React.DetailedHTMLProps<
           React.AnchorHTMLAttributes<HTMLAnchorElement>,
           HTMLAnchorElement
         >,
    }
  }
}

While I think that this would be a great change to implement, I'm tempted to go a step further.

A Step Further

Why do DOM-related definitions exist under the React namespace? These host component types, attributes, and events are only relevant in DOM environments, so why not move them to the ReactDOM namespace?

// @types/react-dom/index.d.ts
namespace ReactDOM {
  interface ReactHTML { ... }
}

declare module 'react' {
  function createElement(
    type: keyof ReactDOM.ReactHTML, // a, div, etc.
    // ...
  ): ReactElement
}

declare global {
  namespace JSX {
    interface IntrinsicElements {
      a: ReactDOM.DetailedHTMLProps<
           ReactDOM.AnchorHTMLAttributes<HTMLAnchorElement>,
           HTMLAnchorElement
         >,
      // ...
    }
  }
}

This would be a major breaking change.

In the short term, developers would need to update their existing types, which is never fun. Third-party type definitions are strange because they don't really follow semantic versioning; any update can be a breaking change, but that isn't an excuse to make breaking changes.

What about the long term? Will projects benefit from putting DOM types under the ReactDOM namespace? I'd like to think so. Getting DOM definitions from the ReactDOM namespace "feels" better to me.

DefinitelyTyped is a community of developers working to put the best types out. So what do you think? Where would you define an anchor's attributes or a mouse click event? Status quo or re-organize?

import React from "react";

import { ButtonHTMLAttributes } from "react-dom";

export default function UglyButton(props: ButtonHTMLAttributes) {
  const style = {
    color: "red",
    background: "green"
  }
  return (
    <button style={style} {...props} />
  );
}

Annotations

  1. While TypeScript is becoming more popular, many packages on NPM do not provide their own type definitions. For these packages, the DefinitelyTyped repo is the predominant source for type definitions. The DefinitelyTyped community does a great job ensuring that there are proper typings for thousands of packages.

  2. DefinitelyTyped publishes type definitions on NPM under the @types scope. TypeScript knows to look for types in node_modules/@types.

  3. I have implemented these changes, but haven't opened a PR yet. While the heart of the change is moving files from @types/react to @types/react-dom, the change touches over 200 other packages.

    The other package changes are a mixture of packages that use DOM types in their definitions and ones that use DOM JSX in their tests. Getting them working with this change was a matter of adding type references (triple-slash directives) to their respective files. All but two packages worked once given these references (the remaining two I am still puzzling out, but they are edge cases).