microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.26k stars 12.38k forks source link

Locally scoped & typed jsxFactory #44018

Open fabiancook opened 3 years ago

fabiancook commented 3 years ago

Suggestion

πŸ” Search Terms

jsx jsxFactory

I found related tickets, but this ticket information is very long winded and I believe this requires its own ticket:

βœ… Viability Checklist

My suggestion meets these guidelines:

This would be a type level only change, updating existing behaviour to be more inline with direct local scope types instead of reliance on global scope, for this reason, I believe all of the above items are true.

⭐ Suggestion

Type completion from a defined jsxFactory function definition

πŸ“ƒ Motivating Example

Here is a type definition covering the internal process that occurs within the defined createNode function in the same file.

Sample:

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
  <TO extends O, S extends CreateNodeFragmentSourceFirstStage>(source: S, options?: TO, ...children: C[]): FragmentVNode & {
    source: S;
    options: TO;
  };
}

GitHub Link

The linked type definition allows for complex types to be defined, including static type resolution for leaves or scalar values:

it.concurrent.each<[SourceReference]>([
  [Symbol("Unique Symbol")],
  [true],
  [false],
  [1],
  [0],
  [1n],
  [0n],
  [""],
  ["Hello!"],
])("%p should produce a scalar node", async <I extends SourceReference>(input: I ) => {
  const output = createNode(input);
  expect(isScalarVNode(output)).toEqual(true);
  const source: I = output.source;
  expect(source).toEqual(input);
});

GitHub Link

From the perspective of a consumer, all of the above values will produce an object that contains source that matches the original input value. These nodes will never produce children so the type definition accounts for this by using

{
  children: never;
}

This is done by this section of the definition

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
<TO extends O, S extends SourceReference>(source: S): VNode & {
    source: S;
    options: never;
    scalar: true;
    children: never;
  };
  <TO extends O, S extends SourceReference>(source: S, options?: TO): VNode & {
    source: S;
    options: TO;
    scalar: true;
    children: never;
  };
  <TO extends O, S extends SourceReference>(source: S, options?: TO, ...children: C[]): VNode & {
    source: S;
    options: TO;
    scalar: false;
  };
}

From a type perspective, the the values that are known by the caller are returned in the return type. We also get whether or not children is available (or the other side of this, whether the node is a scalar node, where no children were passed)

Given jsxFactory can be typed using this function, then children itself can also be typed

children not typed:

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
  <TO extends O, S extends CreateNodeFragmentSourceFirstStage>(source: S, options?: TO, ...children: C[]): 
  FragmentVNode & {
    source: S;
    options: TO;
  }
}

GitHub Link

children typed:

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
  <TO extends O, S extends CreateNodeFragmentSourceFirstStage, TC extends C = C>(source: S, options?: TO, ...children: TC[]): FragmentVNode & {
    source: S;
    options: TO;
    children: AsyncIterable<ResolveNodeType<TC>[]>;
  };
}

Given the above type was implemented, these types would be able to:

While the above code is scalar values, the static leaves of a state tree, functions can be fully typed as well... only if the first point is true.

While it would make this definition vastly more complex, I believe it still is possible from a pure type perspective

Currently a function returns a fragment node, which produces only state through children.

it.concurrent.each<[CreateNodeFragmentSourceFirstStage]>([
  [() => {}],
  [Promise.resolve()],
  [Fragment],
])("%p should produce a fragment node", async (input) => {
  const output: FragmentVNode = createNode(input);
  expect(isFragmentVNode(output)).toEqual(true);
});

GitHub Link

The matching types for functions and promises currently drop the type of children once passed.

export type CreateNodeFragmentSourceFirstStage =
  | Function
  | Promise<unknown>
  | typeof Fragment;

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
  <TO extends O, S extends CreateNodeFragmentSourceFirstStage>(source: S, options?: TO, ...children: C[]): 
  FragmentVNode & {
    source: S;
    options: TO;
  };
}

The retain these we need to type children only additionally:

export type CreateNodeFragmentSourceFirstStage =
  | Function
  | Promise<unknown>
  | typeof Fragment;

interface CreateNodeFn<
  O extends object = object,
  S = Source<O>,
  C extends VNodeRepresentationSource = VNodeRepresentationSource
> {
  <TO extends O, S extends CreateNodeFragmentSourceFirstStage, TC extends C = C>(source: S, options?: TO, ...children: TC[]): FragmentVNode & {
    source: S;
    options: TO;
    children: AsyncIterable<ResolveNodeType<TC>[]>;
  };
}

Because the types of all children nodes will be typed by the time they are defined as vnodes, all types will be complete.

From a top level at this point we would be able to statically resolve a complete type set from a tree of components.

πŸ’» Use Cases

Local string types:

https://github.com/microsoft/TypeScript/issues/15217

Imported string | number | bigint | symbol | boolean types:

vnode defines "token" types, which allows definition of a partial component with no implementation.

<OfferCatalog>
    <Product
      name="This is my name 1"
      sku="SKU 123"
    >
        <Brand name="Some brand" />
    </Product>
    <Product
      name="This is my name 2"
      sku="SKU 124"
    >
        <Brand name="Some brand" />
    </Product>
    <Product
      name="This is my name 3"
      sku="SKU 125"
    >
        <Brand name="Some other brand" />
    </Product>
</OfferCatalog>
<Order
  identifier="1"
  orderDate={new Date()}
>
    <Invoice identifier="2313132">
        <PaymentMethod identifier="123243234" />
    </Invoice>
    <Product
      sku="SKU 125"
    />
    <DeliveryMethod identifier="123122222">
        <Country name="New Zealand" />
    </DeliveryMethod>
</Order>

GitHub Link

This can be used to directly create a set of jsx based components without implementation, if these were fully typed, this could be read statically excluding the value of Date (while still knowing it was a Date)

<scxml>

    <datamodel>
        <data id="eventStamp"/>
        <data id="rectX" expr="0"/>
        <data id="rectY" expr="0"/>
        <data id="dx"/>
        <data id="dy"/>
    </datamodel>

    <state id="idle">
        <transition event="mousedown" target="dragging">
            <assign location="eventStamp" expr="_event.data"/>
        </transition>
    </state>

    <state id="dragging">
        <transition event="mouseup" target="idle"/>
        <transition event="mousemove" target="dragging">
            <assign location="dx" expr="eventStamp.clientX - _event.data.clientX"/>
            <assign location="dy" expr="eventStamp.clientY - _event.data.clientY"/>
            <assign location="rectX" expr="rectX - dx"/>
            <assign location="rectY" expr="rectY - dy"/>
            <assign location="eventStamp" expr="_event.data"/>
        </transition>
    </state>

</scxml>

GitHub Link

The above code is a jsx depending on types defined within the same module.

scxml: SCXMLAttributes;
datamodel: DataModelAttributes;
data: DataAttributes;
state: StateAttributes;
transition: TransitionAttributes;
assign: AssignAttributes;

GitHub Link

Actions can also be defined statically, which if typed, would be readable statically.

export const Action: ActionNode = createToken(ActionSymbol);

GitHub Link

Related Tickets

Given the implementation of https://github.com/microsoft/TypeScript/issues/34319 an implementor will be able to use

function h(source, options, ...children) implements CreateNodeFn;

Which can then be restricted by providing generics.

fabiancook commented 3 years ago

This is the resolution process I am following for "children" that are passed to the h function before they are consumed externally

export type ChildrenSourceResolution<C> =
  C extends undefined ? unknown :
  C extends Promise<infer R> ? ChildrenSourceResolution<R> :
  C extends FragmentVNode ? C extends { children: AsyncIterable<(infer R)[]> } ? ChildrenSourceResolution<R> : unknown :
  C extends VNode ? C :
  C extends SourceReference ? ScalarVNode & { source: C } :
  C extends MarshalledVNode ? VNode & Exclude<C, "children">:
  C extends AsyncGenerator<infer R> ? ChildrenSourceResolution<R> :
  C extends Generator<infer R> ? ChildrenSourceResolution<R> :
  C extends AsyncIterable<infer R> ? ChildrenSourceResolution<R> :
  C extends Iterable<infer R> ? ChildrenSourceResolution<R> :
  VNode;

This resolves as expected:

In this case I was returning two unique symbol types

image

In this case I am returning three unique symbol types

image

Or if we changed to strings:

    function A() {
      return createNode("This is the content of A");
    }

    function B() {
      return createNode("This is the content of B");
    }

    function C() {
      return createNode("This is the content of C");
    }

    function D() {
      return [
        createNode(A),
        createNode(B)
      ];
    }

    async function *E() {
      yield createNode(D);
      yield createNode(C);
    }

    it("works", async () => {
      const node = createNode(E);
      const iterable = node.children;
      for await (const children of iterable) {
        const values = children.map(node => node.source);
        console.log({ values, children });
      }

    });

image

kevinramharak commented 3 years ago

FYI: When discussed in the typescript discord, this PR came up.

fabiancook commented 3 years ago

I believe this to be a ~complete~ somewhat complete typescript type only jsx implementation.

https://gist.github.com/fabiancook/6be2136396cd1b62e47fd4395f4d4736

If h were stubbed and branded as this CreateNodeFn, we can resolve the state tree statically.

These types are backed by a runtime implementation that has matching functionality. This includes the children resolution pattern mentioned in my previous comment.

I will try this with https://github.com/microsoft/TypeScript/pull/29818 and see how I go!


Updated types: TypeScript Playground Relevant GitHub Code

fabiancook commented 2 years ago

I have isolated an example from the original issue.

In JavaScript, or TypeScript without JSX, the example:

const scxml = <scxml>

    <datamodel>
        <data id="eventStamp"/>
        <data id="rectX" expr="0"/>
        <data id="rectY" expr="0"/>
        <data id="dx"/>
        <data id="dy"/>
    </datamodel>

    <state id="idle">
        <transition event="mousedown" target="dragging">
            <assign location="eventStamp" expr="_event.data"/>
        </transition>
    </state>

    <state id="dragging">
        <transition event="mouseup" target="idle"/>
        <transition event="mousemove" target="dragging">
            <assign location="dx" expr="eventStamp.clientX - _event.data.clientX"/>
            <assign location="dy" expr="eventStamp.clientY - _event.data.clientY"/>
            <assign location="rectX" expr="rectX - dx"/>
            <assign location="rectY" expr="rectY - dy"/>
            <assign location="eventStamp" expr="_event.data"/>
        </transition>
    </state>

</scxml>

Would be implemented as:

const scxml = h("scxml", { },
    h("datamodel", {},
        h("data", { id: "eventStamp" } as const),
        h("data", { id: "rectX", expr: "0" } as const),
        h("data", { id: "rectY", expr: "0" } as const),
        h("data", { id: "dx" } as const),
        h("data", { id: "dy" } as const)
    ),
    h("state", { id: "idle" } as const,
        h("transition", { event: "mousedown", target: "dragging" } as const,
            h("assign", { location: "eventStamp", expr: "_event.data" } as const)
        )
    ),
    h("state", { id: "dragging" } as const,
        h("transition", { event: "mouseup", target: "idle" } as const),
        h("transition", { event: "mousemove", target: "dragging" } as const,
            h("assign", { location: "dx", expr: "eventStamp.clientX - _event.data.clientX" } as const),
            h("assign", { location: "dy", expr: "eventStamp.clientY - _event.data.clientY" } as const),
            h("assign", { location: "rectX", expr: "rectX - dx" } as const),
            h("assign", { location: "rectY", expr: "rectY - dy" } as const),
            h("assign", { location: "eventStamp", expr: "_event.data" } as const)
        )
    )
);

Using these types: TypeScript Playground

The following resulting type is produced:


const root: {
    source: "scxml",
    children: AsyncIterable<(
        {
            source: "datamodel",
            children: AsyncIterable<(
                {
                    source: "data",
                    options: {
                        id: "rectX" | "rectY" | "dx" | "dy" | "eventStamp",
                        expr?: string;
                    },
                    children: AsyncIterable<{
                        source: "assign",
                        options: {
                            location: "rectX" | "rectY" | "dx" | "dy" | "eventStamp",
                            expr: string
                        },
                        children: never
                    }[]>
                }
            )[]>
        } |
        {
            source: "state",
            options: {
                id: "idle" | "dragging"
            },
            children: AsyncIterable<(
                {
                    source: "transition",
                    options: {
                        event: "mousemove" | "mouseup" | "mousedown",
                        target: "idle" | "dragging"
                    },
                    children: AsyncIterable<{
                        source: "assign",
                        options: {
                            location: "rectX" | "rectY" | "dx" | "dy" | "eventStamp",
                            expr: string
                        },
                        children: never
                    }[]>
                }
            )[]>
        }
    )[]>
} = scxml;

The complete structure of this JSX component is present within the resulting types, including all variations of expressions used.

Without redefining the types (using scxml directly, not root from above) we can loop through these nodes and follow the types along, which each level we get a narrowing of the types available

for await (const children of root.children) {
    for (const node of children) {
        if (node.source === "state") {
            const stateId: "idle" | "dragging" = node.options.id;
            for await (const transitions of node.children) {
                for (const transition of transitions) {
                    if (transition.source === "transition") {
                        const event: "mousedown" | "mouseup" | "mousemove" = transition.options.event;
                        const target: "idle" | "dragging" = transition.options.target;
                        for await (const assigns of transition.children) {
                            for (const assign of assigns) {
                                const assignSource: "assign" = assign.source;
                                const { location, expr } = assign.options;
                                const locationString: string = location;
                                const exprString: string = expr;
                            }
                        }
                    }
                }
            }
        } else if (node.source === "datamodel") {
            for await (const dataArray of node.children) {
                for (const data of dataArray) {
                    const dataSource: "data" = data.source;
                    const { id } = data.options;
                    const idString: string = id;
                    if (data.options.id === "rectX" || data.options.id === "rectY") {
                        const { expr } = data.options;
                        const exprString = expr;
                    }
                }
            }
        }
    }
}

We can see that at the level of data and transition we have the expected narrowed types available.

Given static level types are resolvable and terminate themselves, where a component is used instead, the return type can be considered resolved as well, meaning we can lean on earlier resolution within the component to know the resulting types.

If I wrap the above code in a function with the signature:

async function doThing(root: typeof scxml): Promise<void>

Then I can invoke it with all these variations of usage, and get a the expected type passed to doThing in each case:

await doThing(scxml);

const asFunction = h(() => scxml);

for await (const children of asFunction.children) {
    for (const child of children) {
        await doThing(child);
    }
}

const asGenerator = h(
    async function *K() {
        yield scxml;
        yield scxml;
    }
)

for await (const children of asGenerator.children) {
    for (const child of children) {
        await doThing(child);
    }
}

Ignoring whether or not the actual jsx factory utilises the types provided by h is irrelevant if we look from the point of view of external consumers of a module, where all jsx could be compiled into using the factory, leaving the defined types cemented into the resulting code, however it would be nice to be able to "natively" use the type returned from the jsxFactory function when writing the original code.

fabiancook commented 2 years ago

Here is a live code sandbox & module with the types isolated for the h function.

https://codesandbox.io/s/cool-pond-yqnkj?file=/src/index.ts https://www.npmjs.com/package/@virtualstate/typescript

I'm not certain if I have covered every possible case, but I know at least at the surface level these types are working the way I expect them to (showing meta can be hovered over and the returned type from the function component above is used):

image