question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Proposal: stronger JSX types through conditional types

See original GitHub issue

Suggestion

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 types 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.
  • 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:open
  • Created 5 years ago
  • Reactions:11
  • Comments:7

github_iconTop GitHub Comments

1reaction
Jessidhiacommented, Dec 12, 2018

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 😅

// 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
    }
  }
}

function hocFactory<T extends ESX.Component<{ className: string }>>(Component: T) {
  return function Wrapper(props: ESX.ComponentProps<T>): ESX.Element<T> {
    return {
      type: Component,
      key: null,
      props: {
        // not spreading these props here is a type error 🎉
        ...props,
        className: 'string'
      },
      ref: null
    }
  }
}

const DivHoc = hocFactory('div')
const DivHocElement: ESX.Element<typeof DivHoc> = {
  type: DivHoc,
  key: null,
  props: {},
  ref: null
}

declare const Memo2: ESX.ExoticComponents.MemoComponent<(props: { className: string }) => string>
const Memo2Hoc = hocFactory(Memo2)
const Memo2HocElement: ESX.Element<typeof Memo2Hoc> = {
  type: Memo2Hoc,
  key: null,
  props: {
    // TODO: find a way to make hocFactory be able to omit or optionalize the props it provides
    className: 'foo'
  },
  ref: null
}

// $ExpectError
const ErrorForwardHoc = hocFactory(ForwardTest)
declare const Forward2: ESX.ExoticComponents.ForwardComponent<
  ESX.ExoticComponents.ForwardComponentRender<{ className?: string }, any>
>
const Forward2Hoc = hocFactory(Forward2)
const Forward2HocElement: ESX.Element<typeof Forward2Hoc> = {
  type: Forward2Hoc,
  key: null,
  props: {},
  ref: null
}

// 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<P extends object = any> =
      | ((props: P) => FragmentResult)
      | (new (props: P) => { render(): FragmentResult })
      | {
          [K in keyof typeof IntrinsicComponents]: ComponentAcceptsProps<K, P>
        }[keyof typeof IntrinsicComponents]
      | ExoticComponent<P>
    type ExoticComponent<P extends object = any> =
      | ExoticComponents.ForwardComponent<ExoticComponents.ForwardComponentRender<P, any>>
      | ExoticComponents.MemoComponentWithProps<P>
      | ExoticComponents.ExoticComponentAcceptsProps<ExoticComponents.ModeComponent<any>, P>

    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 ComponentAcceptsProps<
      T extends Component,
      P extends object
    > = T extends keyof typeof IntrinsicComponents
      ? (P extends IntrinsicComponentProps<T> ? T : never)
      : T extends (props: infer O) => FragmentResult
      ? (P extends O ? T : never)
      : T extends new (props: infer O) => { render(): FragmentResult }
      ? (P extends O ? T : never)
      : T extends ExoticComponents.ExoticComponentBase<ExoticComponentTypes>
      ? ExoticComponents.ExoticComponentAcceptsProps<T, P>
      : 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<any, any>> = 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

      type ExoticComponentAcceptsProps<
        T extends ExoticComponentBase<ExoticComponentTypes>,
        P extends object
      > = never

      interface ModeComponent<
        S extends typeof Fragment | typeof Suspense | typeof ConcurrentMode | typeof StrictMode
      > extends ExoticComponentBase<S> {}

      interface MemoComponentWithProps<P extends object> extends MemoComponent<Component<P>> {}

      interface MemoComponent<T extends Component> extends ExoticComponentBase<typeof Memo> {
        type: T
      }

      type ForwardComponentRender<P extends object, R> = (
        props: P,
        ref: React.Ref<R>
      ) => FragmentResult

      interface ForwardComponent<T extends ForwardComponentRender<any, any>>
        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
  }
}
0reactions
ferdabercommented, Dec 13, 2018

Ah cool, I need some more time to grok this, I only wish I can leave inline comments on your comments 😄

Read more comments on GitHub >

github_iconTop Results From Across the Web

Conditional Props With Typescript | by Kushal Agrawal
This article mainly summarises how can conditional props help you with defining a correct set of props for your components and can help...
Read more >
How to implement Conditional Props using TypeScript in React
In this video, I explain what conditional props are and how to use TypeScript to conditionally provide props for a component.
Read more >
TypeScript conditional types for props - reactjs - Stack Overflow
I have a component that can be in two different states, editing mode and viewing mode. To accommodate for that I have a...
Read more >
Advanced TypeScript: The Power and Limitations of ... - Medium
TypeScript opens this up to us through conditional types. They provide a way for us to make logical decisions at the type level....
Read more >
Documentation - Conditional Types - TypeScript
Often, the checks in a conditional type will provide us with some new information. Just like with narrowing with type guards can give...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found