typescripttypesjsx

Custom JSX factory not properly typed


I am trying to use the JSX syntax to allow users to create behavior trees with a nice hierarchical syntax:

const EnsureTargetNearby = (
    <Fallback comment="Ensure mob nearby">
        <IsMonsterNearby mobTypes={mobTarget} />
        <MoveTo destinationOrKey={mobTarget} />
    </Fallback>
);

const EnsureAlive = (
    <Fallback comment="Ensure char alive">
        <IsAlive />
        <ThrottleDecorator delay={1000}>
            <Respawn />
        </ThrottleDecorator>
    </Fallback>
);

However, I'm getting type issues related to the children prop. This error occurs on each node that expects one or more children:

Property 'children' is missing in type '{ comment: string; }' but required in type 'FallbackProps'.

I have prepared a minimal example showcasing the error: Link to playground

The full code in case the playground becomes obsolete at some point:

/*
 * @jsx jsx
 */

// Base node for the Behavior Tree
export abstract class BTNode {
    abstract tick(): boolean;
}

export type JSXNode = BTNode;

export interface JSXChildren {
    children?: JSXNode | JSXNode[];
}

export type FunctionComponent = (props: Record<string, unknown>) => BTNode;

export type ClassComponent = new (props: Record<string, unknown>) => BTNode;

declare namespace JSX {
    export interface IntrinsicElements { }

    // Declare the shape of JSX rendering result
    // This is required so the return types of components can be inferred
    export type Element = BTNode;

    // Declare the shape of JSX components
    export type ElementClass = BTNode;
}

// eslint-disable-next-line @typescript-eslint/ban-types
const isESClass = (fn: Function): fn is new (...args: any[]) => any =>
    typeof fn === "function" &&
    Object.getOwnPropertyDescriptor(fn, "prototype")?.writable === false;

export function jsx(
    tag: FunctionComponent | ClassComponent,
    props: Record<string, unknown>,
    ...children: BTNode[]
): JSX.Element {
    const fullProps = { ...props, children };

    if (tag instanceof BTNode) {
        return tag;
    }

    if (isESClass(tag)) {
        const ClassTag = tag as new (props_: Record<string, unknown>) => JSX.Element;
        return new ClassTag(fullProps);
    }

    return tag(fullProps);
}



export interface FallbackProps {
    comment: string;
    children: BTNode[];
}

export class Fallback extends BTNode {
    protected children: BTNode[] = [];
    comment: string;

    constructor({ comment, children }: FallbackProps) {
        super();

        this.children = children;
        this.comment = comment;
    }

    override tick(): boolean {
        for (const child of this.children) {
            const status = child.tick();

            if (status !== false) {
                return status;
            }
        }

        return false;
    }
}

export class AlwaysTrue extends BTNode {
    override tick(): boolean {
        return true;
    }
}

// The error is on the opening Fallback tag below
const behaviorTree = (
    <Fallback comment="My description">
        <AlwaysTrue />
        <AlwaysTrue />
    </Fallback>
)

With my current configuration it seems like the JSX namespace is entirely ignored. If I changed to stupid types like null or string, nothing changes in the type errors.
Despite the type errors, this code executes perfectly well once forcibly transpiled. It's purely the type that is not working.

I initially followed the tutorial on https://dev.to/afl_ext/how-to-render-jsx-to-whatever-you-want-with-a-custom-jsx-renderer-cjk but I never managed to make jsxImportSource work.

I tried reading https://www.typescriptlang.org/docs/handbook/jsx.html but the docs are a bit too high level and don't go into much details regarding the specifics. Especially regarding the JSX namespace which I don't understand how to make properly available.

Of course I tried looking for people with similar problems but nothing helped:

Edit with working playground

Thanks to the accepted answer, I've fixed the playground which now works as expected: Link to playground


Solution

  • This was discussed on TypeScript discord, and it transpired that modifying the global JSX namespace required it to be inside a declare global block, because it is a global interface not a module-scoped interface, and also that JSX.ElementChildrenAttribute needed to be set to tell TypeScript how the JSX compiler handles children.

    declare global {
        namespace JSX {
            export interface IntrinsicElements { }
    
            // Declare the shape of JSX rendering result
            // This is required so the return types of components can be inferred
            export type Element = BTNode;
    
            // Declare the shape of JSX components
            export type ElementClass = BTNode;
    
            // Tell TS what happens to children
            interface ElementChildrenAttribute {
                children: {}; // specify children name to use
            }
        }
    }