DEV Community

Ferdy Budhidharma
Ferdy Budhidharma

Posted on • Edited on

TypeScript and JSX Part II - What can create JSX?

In Part I of this series, we learned basic JSX syntax and some basic terminology when discussing the abstract syntax tree of JSX expressions. Let's now dive into how TypeScript checks the type validity of JSX expressions.

Not everything can be valid JSX constructors, you can't just shove any random value into the opening tag of a JSX expression:

// bad! it's actually 'a'
const badAnchor = <anchor href='dev.to'>Go to dev.to!</anchor>

// bad! it's not a function!
const MyComponent = {}
const badFunctionElement = <MyComponent>Hi!</MyComponent>

// bad! it's not something that can render!
class MyClassComponent {
  constructor(props: any) { this.props = props }
}
const badClassElement = <MyClassComponent>Hi!</MyClassComponent>

So how does TypeScript know when something is a valid JSX element constructor? The answer lies in the magical JSX namespace. Remembering how the jsxFactory compiler option (or the @jsx pragma) works, we have that the factory function for React is React.createElement. You might also be using some other library, where the factory function is often called h:

// @jsx React.createElement
import React from 'react'

// @jsx h
import { h } from 'preact'

TypeScript will attempt to look up a namespace called JSX under the factory function and fallback to a global one if none is found:

  • for factory functions that are under another namespace, like React.createElement, it will look for React.JSX
  • for factory functions that are just a naked identifier, like h, it will look for h.JSX
  • if no JSX namespace is found, it looks for a global JSX namespace

The React type definitions declares a global JSX namespace, though that's not a good idea and we should change that soon 😅.

So what's the use of the JSX namespace? TypeScript looks for specific interfaces under it to figure out what's acceptable for each type of JSX element constructor:

  • for "intrinsic" element constructors (lower-case tag name), it looks if a property with that same key exists under JSX.IntrinsicElements.
  • for function element constructors, it checks if its return type is assignable to the JSX.Element interface.
  • for class-based element constructors, it checks if its instance type is assignable to the JSX.ElementClass interface.

Let's look at each case in detail:

Intrinsic Element Constructors

If your JSX namespace looks like this:

namespace JSX {
  interface IntrinsicElements {
    a: HTMLAttributes<HTMLAnchorElement>
    button: HTMLAttributes<HTMLButtonElement>
    div: HTMLAttributes<HTMLElement>
    span: HTMLAttributes<HTMLElement>
  }
}

Then you can render these elements:

const validIntrinsicElements = [<a />, <button />, <div />, <span />]
// error properties 'select', 'main', and 'nav' do not exist on type 'JSX.IntrinsicElements'
const invalidIntrinsicElements = [<select />, <main />, <nav />]

We'll talk about what the types of the properties themselves actually mean in the next part of the series.

Function Element Constructors

If your JSX namespace looks like this:

namespace JSX {
  interface Element {
    key?: string
    type: string | (() => any)
    props: { [propName: string]: any }
  }
}

And you have a function like this:

function MyComponent(props: any) {
  return {
    type: MyComponent,
    props: props
  }
}

Then you have a valid constructor! Because its return type is assignable to JSX.Element:

const myFunctionElement = <MyComponent /> // good to go!

How is it though, that when you have a function without its return type annotated, but it returns JSX, it's still okay? That's because TypeScript will treat any JSX expression's type to be the same type as JSX.Element!

function MyComponent() {
  return <div>Hi!</div>
}

const myFunctionElement = <MyComponent /> // still okay

const nakedElement = <div>hi!</div>
type NakedElementType = typeof nakedElement // the type is JSX.Element

An astute reader will notice that this has some odd pitfalls when it comes to what React allows you to return from a component. Remember that React allows you to return arrays, strings, numbers, and booleans from a component, which it will happily render:

function MyStringFragment() {
  return ['a', 'b', 'c'] // type is string[]
}

const myFragment = <MyStringFragment /> // TS error!

Uh oh, this is an unfortunate limitation of the type checker; if we want to get the check to pass, we need to assert the type of the return value:

function MyStringFragment() {
  return ['a', 'b', 'c'] as any as JSX.Element
}

const myFragment = <MyStringFragment /> // good now!

There is an open issue for the TypeScript repo that will hopefully resolve this issue in the future: https://github.com/Microsoft/TypeScript/issues/14729.

Class Element Constructors

If your JSX namespace looks like this:

namespace JSX {
  interface ElementClass {
    render(): any
  }
}

And you have a class like this:

class Component {
  constructor(props: any) {
    this.props = props
  }

  render() {
    return { obviouslyNotAnElement: 'fooled ya!' }
  }

  someOtherMethod(): string
}

Then you have a valid constructor! Because its instance type is assignable to JSX.ElementClass:

const myComponentInstance = new Component({})

type myComponentInstanceType = {
  render(): { obviouslyNotAnElement: string }
  someOtherMethod(): string
}

type ComponentInstanceType = {
  render(): any
}

Obviously the real React type is different, but that's why we always extend from React.Component, because this is what it roughly looks like in React's types:

namespace React {
  type Renderable = JSX.Element | JSX.Element[] | number | string | boolean | null
  class Component {
    /* other methods like setState, componentDidUpdate, componentDidMount, etc */
    render(): Renderable
  }

  namespace JSX {
    interface ElementClass {
      render(): Renderable
    }
  }
}

And now any class that you declare that extends React.Component will be a valid constructor!

In summary: before we even talk about props, TypeScript has to check if a component is actually a valid JSX constructor, otherwise it rejects it when you try to use it in a JSX expression.

In the next post in this series, we'll be talking about what TypeScript considers to be valid attributes given a specific JSX expression (remember: attributes are the props you give to a JSX expression, like HTML element attributes).

Top comments (3)

Collapse
 
trusktr profile image
Joe Pea • Edited

Is there some way to make a JSX expression like <div></div> have a type other than JSX.Element without casting it?

I'm wondering, because with Solid (one of the fastest view libs), which uses JSX for convenient syntax for creating DOM with reactive expressions, the type of div in

cons div = <div></div>

// it is a div element, we can use DOM APIs:
div.setAttribute('foo', 'bar')

in plain JavaScript is actually HTMLDivElement; div is a reference to an actual <div> element after the assignment.

I'm wondering if there's a way to make that typing possible, without having to write something like

const div = (<div></div>) as unknown as HTMLDivElement
Collapse
 
simonrobertson profile image
Simon Robertson

Hello,

Where is HTMLAttributes coming from? TSC is saying it does not exist.

Collapse
 
simonrobertson profile image
Simon Robertson

No worries, worked it out :)

It's the interface that defines the valid attribute/props for a JSX element.