Proposal: stronger JSX types through conditional types
See original GitHub issueSuggestion
The current way the JSX
namespace works and is implemented in the compiler is… full of legacy stuff. This could probably be fixed by https://github.com/Microsoft/TypeScript/issues/14729, but even when that is made, we still need some way of dealing with the types of intrinsic attributes.
I’m not quite sure how to articulate my proposal, take this as a weak draft/WIP, but to sketch my idea, compare the following snippet with the way the JSX
namespace is currently defined in @types/react
.
I wrote some tests kind of inline, and I named it “ESX” for now because I wrote it inside an existing project to verify the types worked.
// tests
const Test = (_: { test: boolean }) => false
const p: ESX.ComponentProps<typeof Test> = {
test: true
}
const pFail: ESX.ComponentProps<typeof Test> = {
test: true,
children: null // $ExpectError
}
class Test2 extends React.Component<{ test: boolean }> {
render() {
return false
}
}
const p2: ESX.ComponentProps<typeof Test2> = {
test: true
}
declare const Suspense: ESX.ExoticComponents.ModeComponent<typeof ESX.ExoticComponents.Suspense>
const p3: ESX.ComponentProps<typeof Suspense> = {
fallback: null
}
declare const Fragment: ESX.FragmentComponentType
const p4: ESX.ComponentProps<typeof Fragment> = {}
const aProps: ESX.ComponentProps<'a'> = {
href: 'test',
onClick({ currentTarget }) {
currentTarget.href
}
}
declare const MemoTest: ESX.ExoticComponents.MemoComponent<typeof Test>
const memoProps: ESX.ComponentProps<typeof MemoTest> = {
test: true
}
function forwarder(props: ESX.ComponentProps<typeof Test>, ref: React.Ref<HTMLAnchorElement>) {
const children: ESX.Element<typeof MemoTest> = {
type: MemoTest,
key: null,
props: {
test: true
},
ref: null
}
const element: ESX.Element<'a'> = {
type: 'a',
key: null,
props: {
children
},
ref
}
const fragment: ESX.Element<typeof Fragment> = {
type: Fragment,
key: null,
props: {
children: [element, 'foo']
},
ref: null
}
return fragment
}
declare const ForwardTest: ESX.ExoticComponents.ForwardComponent<typeof forwarder>
const forwardProps: ESX.ComponentProps<typeof ForwardTest> = {
test: true
}
const forwardElement: ESX.Element<typeof ForwardTest> = {
type: ForwardTest,
key: 'foo',
props: {
test: true
},
ref(ref) {
if (ref !== null) {
ref.href
}
}
}
// actual declarations
declare global {
namespace ESX {
type EmptyElementResult = boolean | null
type SingleElementResult<T extends Component = any> = string | number | Element<T>
type FragmentResult<T extends Component = any> = EmptyElementResult | SingleElementResult<T> | FragmentResultArray<T>
interface FragmentResultArray<T extends Component = any> extends ReadonlyArray<FragmentResult<T> | undefined> {}
type Component =
| ((props: any) => FragmentResult)
| (new (props: any) => { render(): FragmentResult })
| keyof typeof IntrinsicComponents
| ExoticComponent
type ExoticComponent =
| ExoticComponents.ForwardComponent<any>
| ExoticComponents.MemoComponent<any>
| ExoticComponents.ModeComponent<any>
const ChildrenPropName: 'children'
type FragmentComponentType = ExoticComponents.ModeComponent<typeof ExoticComponents.Fragment>
interface IntrinsicAttributes<T extends Component> {
key?: string
ref?: (ComponentRefType<T> extends never ? never : React.Ref<ComponentRefType<T>>) | null
}
type ApparentComponentProps<T extends Component> = IntrinsicAttributes<T> & ComponentProps<T>
type ComponentProps<T extends Component> = T extends keyof typeof IntrinsicComponents
? IntrinsicComponentProps<T>
: T extends (props: infer P) => FragmentResult
? P
: T extends new (props: infer P) => { render(): FragmentResult }
? P
: T extends ExoticComponents.ExoticComponentBase<ExoticComponentTypes>
? ExoticComponents.ExoticComponentProps<T>
: never
type IntrinsicComponentProps<
T extends keyof typeof IntrinsicComponents
> = typeof IntrinsicComponents[T] extends HostComponent<infer P, any> ? P : never
type ComponentRefType<T extends Component> = T extends keyof typeof IntrinsicComponents
? IntrinsicComponentRef<T>
: T extends ExoticComponents.ForwardComponent<infer C>
? C extends (props: any, ref: React.Ref<infer R>) => FragmentResult
? R
: never
: T extends (new (props: any) => infer R)
? R
: never
type IntrinsicComponentRef<
T extends keyof typeof IntrinsicComponents
> = typeof IntrinsicComponents[T] extends HostComponent<any, infer R> ? R : never
interface Element<T extends Component> {
type: T
props: ComponentProps<T>
key: string | null
ref: React.Ref<ComponentRefType<T>> | null
}
type ExoticComponentTypes = typeof ExoticComponents[keyof typeof ExoticComponents]
// these are non-callable, non-constructible components
// the names inside them are to be used by the React types instead,
// and are only here to be able to declare their props/refs to
// the typechecker.
namespace ExoticComponents {
interface ExoticComponentBase<S extends ExoticComponentTypes> {
$$typeof: S
}
const Memo: unique symbol
const ForwardRef: unique symbol
const Fragment: unique symbol
const Suspense: unique symbol
const ConcurrentMode: unique symbol
const StrictMode: unique symbol
interface ModeComponentProps {
[ChildrenPropName]?: FragmentResult
}
interface SuspenseComponentProps extends ModeComponentProps {
fallback: FragmentResult
maxDuration?: number
}
// A bunch of this complication is that `type`s are
// never allowed to be recursive, directly or indirectly
type MemoComponentProps<T extends Component> = T extends HostComponent<infer P, any>
? P
: T extends (props: infer P) => FragmentResult
? P
: T extends new (props: infer P) => { render(): FragmentResult }
? P
: T extends ForwardComponent<infer C>
? ForwardComponentProps<C>
: never
type ForwardComponentProps<T extends ForwardComponentRender> = T extends (
props: infer P,
ref: React.Ref<any>
) => FragmentResult
? P
: never
type ExoticComponentProps<
T extends ExoticComponentBase<ExoticComponentTypes>
> = T extends ExoticComponentBase<infer S>
? S extends typeof Fragment | typeof ConcurrentMode | typeof StrictMode
? ModeComponentProps
: S extends typeof Suspense
? SuspenseComponentProps
: S extends typeof Memo
? T extends MemoComponent<infer C>
? MemoComponentProps<C>
: never
: T extends ForwardComponent<infer C>
? ForwardComponentProps<C>
: never
: never
interface ModeComponent<
S extends typeof Fragment | typeof Suspense | typeof ConcurrentMode | typeof StrictMode
> extends ExoticComponentBase<S> {}
interface MemoComponent<T extends Component> extends ExoticComponentBase<typeof Memo> {
type: T
}
type ForwardComponentRender = (props: any, ref: React.Ref<any>) => FragmentResult
interface ForwardComponent<T extends ForwardComponentRender>
extends ExoticComponentBase<typeof ForwardRef> {
render: T
}
}
}
namespace IntrinsicComponents {
const a: HostComponent<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>
const div: HostComponent<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
}
const HostComponentBrand: unique symbol
interface HostComponent<P, I> {
[HostComponentBrand]: new (props: P) => I
}
}
Use Cases
Make the type definitions for JSX much stronger than they currently are. Using JSX syntax would, through the use of this new intrinsic type declaration style, be able to produce strongly typed elements, avoid the pitfalls of implicit children
, and support actual exotic elements that are not callable or constructible.
This is, of course, not complete at all. I did say it is a draft but it is a starting point for further ideas. This doesn’t address defaultProps
at all for example.
Examples
The examples are in the snippet above, but, when writing JSX:
<ComponentName key='x'>child text node<><div>fragment</div></></ComponentName>
The JSX evaluator would attempt to create "ESX".Element
with ComponentName
, "ESX".FragmentComponentType
and 'div'
as their generic argument, respectively.
The attributes that can be given to the component would come from the ApparentComponentProps<T>
. The attributes the component can read inside itself would be ComponentProps<T>
. This no longer has any risk of having key
or ref
appear to be available as props inside a component, although I haven’t yet found out a way to forbid that you just declare key
or ref
yourself in your props; it’d be caught but only when you attempt to use the component, not on declaration time. This is likely related to the unsolved problem I mention at the end.
Children would count as a ["ESX".ChildrenPropName]
attribute. A TODO is to figure out how to represent the difference React and Preact have when dealing with single children. Right now, a single child (like inside <>
and inside <div>
) create a single "ESX".Element
, while multiple children would create a FragmentResultArray
(probably needs a better name).
Exotic components use “unique symbol” nominal types to be able to declare themselves. Intrinsic elements ('div'
, 'a'
, etc) also use a namespace and const
declarations instead of an interface
as I was looking into using nominal typing for them as well. Host components use a “unique symbol” nominal type to make themselves not constructible while still being able to declare a component that behaves differently from class components, and are still not themselves exotic components.
An unsolved problem is how to declare that you expect an element or a component to have or at least accept certain props. This probably requires higher kinded types, or just an extra spark of the imagination to figure out how to do it. Using conditional types to never
out an argument type if it doesn’t accept the props you want is just terrible DX (<T extends Component>
, ComponentProps<T> extends { propIWant: string } ? T : never
).
I also ran into limitations with type
s not being allowed to be self-referential. You probably want to avoid turning the type checker into a turing machine, but with (for example) React.memo(React.lazy(async () => ({ default: React.memo(React.memo(React.memo(React.forwardRef(() => 'Hello there!')))) })))
being a perfectly valid component at runtime, not being able to recurse causes issues for correctly deriving props. Reminds me I forgot to define the exotic lazy component type.
Checklist
My suggestion meets these guidelines:
- This wouldn’t be a breaking change in existing TypeScript/JavaScript code
- Not really if you just use the React public API, but this is, however, breaking as hell for anything typed directly using the JSX types or
@types/react
non-concrete types. This would mostly affect@types/react
itself, though, but several of@types/react
’s types would have to change to be compatible with this.
- Not really if you just use the React public API, but this is, however, breaking as hell for anything typed directly using the JSX types or
- This wouldn’t change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn’t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
- This feature would agree with the rest of TypeScript’s Design Goals.
Issue Analytics
- State:
- Created 5 years ago
- Reactions:11
- Comments:7
I think I got something that works, but it also required a bunch of code duplication because of having to try really hard to avoid TypeScript complaining about circular types.
There still are other things to solve (and even more TODOs) but right now I have to do some other work 😅
Ah cool, I need some more time to grok this, I only wish I can leave inline comments on your comments 😄