loading...

TypeScript and JSX Part III - Typing JSX children

ferdaber profile image Ferdy Budhidharma Updated on ・3 min read

In the last post we learned about what kinds of types can be JSX constructors, and now it's time to dive deeper into how children work.

The concept of children in JSX is interesting: it can both be in the form of a JSX attribute, as well as nested inside an upper-level JSX expression:

// both are valid ways to write children
const myNestedDiv1 = (
  <div>
    <span>I'm stuck!</span>
  </div>
)

const myNestedDiv2 = (
  <div
    children={<span>I'm stuck!</span>}
  />
)

// what gets rendered?
const myNestedDiv3 = (
  <div
    children={<span>Child A</span>}
  >
    <span>Child B</span>
  </div>
)

In myNestedDiv3, the runtime result is ambiguous, and could potentially vary between libraries! Remembering that JSX is just a syntactic sugar, in React we have this:

// before JSX
const myNestedDiv3Untranspiled = (
  <div
    children={<span>Child A</span>}
  >
    <span>Child B</span>
  </div>
)

// after JSX
const myNestedDiv3Transpiled = React.createElement(
  'div',
  { children: React.createElement('span', null, 'Child A') },
  React.createElement('span', null, 'Child B')
)

Thus, it's actually entirely up to the React runtime to figure out how it wants to render the actual markup. It could prioritize over the children prop, or it could prioritize the rest parameters, or it could do some kind of merge of both!

Because the concept of children is not enforced by the JSX specification, TypeScript provides a way to typecheck the nested JSX expressions against the element attributes type. The element attributes type is the type of all attributes added to a JSX expression, i.e. it's the type of the object passed into the second parameter in React.createElement.

This typechecking is done by specifying a property under the JSX namespace (see the previous post for more info on the JSX namespace) called ElementChildrenAttribute. With a guiding example, let's say your JSX namespace is this:

namespace JSX {
  interface ElementChildrenAttribute {
    enfants: {} // 'children' in french
  }
}

And you have a component defined like so:

interface Props {
  enfants?: JSX.Element | JSX.Element[]
  children?: string | string[]
}

function MyComponent(props: Props) { 
  return <div>{props.enfants}</div>
}

Then the following will happen:

// error! the type `string` is not assignable 
// to the type `JSX.Element | JSX.Element[]` for the `enfants` attribute
const myComponentElement1 = <MyComponent>hello world!</MyComponent>

// good! `enfants` is of type `JSX.Element` which is assignable
// to itself or on array of itself
const myComponentElement2 = (
  <MyComponent>
    <MyComponent />
  </MyComponent>
)

So this is TypeScript's way of defining a connection between nested JSX and some property in the interface that you declare.

How is it that when you use React, you always seem to be able to access this.props.children in a React.Component? Well that's actually just part of the React types themselves, and not some intrinsic feature of TypeScript itself. The React type definitions automatically inject children as an optional property for convenience.

In the example above, we defined some function MyComponent that takes a parameter called props with a specific interface. It returns JSX so it's a valid JSX constructor type.

How does TypeScript know that you can't just add any random attribute to a <MyComponent /> element? That enfants, children, and key are the only ones you can add?

In the next post in the series, we'll learn more about how TypeScript collects all of the possible attributes (and their types) you can add to a component element.

Posted on by:

ferdaber profile

Ferdy Budhidharma

@ferdaber

web developer with an obsession over developer experience

Discussion

pic
Editor guide