microsoft / TypeScript

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

Proposal: stronger JSX types through conditional types #28954

Open Jessidhia opened 5 years ago

Jessidhia commented 5 years ago

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:

ferdaber commented 5 years ago

This looks awesome, we might be able to perform a migration (if this goes through) simultaneously with having React's types declare a non-global JSX namespace.

I have one question -- wouldn't ApparentComponentProps essentially be already solved with LibraryManagedAttributes? As that is the differentiator between a component's "inner props" (accessible inside) vs what attributes are allowed in the JSX expression?

Can you also elaborate on use cases for this?

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 (, ComponentProps extends { propIWant: string } ? T : never).

Jessidhia commented 5 years ago

@ferdaber probably the easiest example is styled-components, as it always only injects the same prop. Because it injects a className: string when rendering the component it's wrapping, it would be good to be able to specify that the component accepts at least { className?: string }.

Actually, I wonder if it would be possible to just do it anyway by modifying the Component and ExoticComponent aliases. I suspect that the reason it isn't possible right now is because of defaultProps and propTypes causing the type to be invariant instead of contravariant.

Jessidhia commented 5 years ago

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
  }
}
Jessidhia commented 5 years ago

I made some more progress but... why is this an implicit any? TS knows the contextual type of the function.

image

The same thing happens with IntrinsicAttributes too, the ref parameter is becoming an implicit any:

image

// tests

function createElement<T extends ESX.Component>(
  Component: T,
  props: ESX.ApparentComponentPropsWithoutChildren<T>,
  ...children: ESX.ChildrenToTupleType<ESX.ChildrenPropType<T>>
): ESX.Element<T>
function createElement<T extends ESX.Component>(
  Component: T,
  props: ESX.ApparentComponentProps<T>
): ESX.Element<T>
function createElement<T extends ESX.Component<any>>(
  Component: T,
  props: ESX.ApparentComponentProps<T>,
  ...children: any[]
): ESX.Element<T> {
  const { key, ref, ...actualProps } = (props || {}) as ESX.IntrinsicAttributes<any>

  return {
    type: Component,
    key: key || null,
    props: (children.length > 0
      ? { ...actualProps, children: children.length === 1 ? children[0] : children }
      : actualProps) as ESX.ComponentProps<T>,
    ref: ref || null
  }
}

const Test = (_: { test: boolean }) => false
const p: ESX.ComponentProps<typeof Test> = {
  test: true
}
const pFail: ESX.ComponentProps<typeof Test> = {
  test: true,
  children: null // $ExpectError
}

createElement(Test, { test: true })

class Test2 extends React.Component<{ test: boolean }> {
  render() {
    return false
  }
}
const p2: ESX.ComponentProps<typeof Test2> = {
  test: true
}
createElement(Test2, { test: true })

declare const Suspense: ESX.ExoticComponents.ModeComponent<typeof ESX.ExoticComponents.Suspense>
const p3: ESX.ComponentProps<typeof Suspense> = {
  fallback: null
}
createElement(Suspense, null) // $ExpectError
createElement(Suspense, {}) // $ExpectError
createElement(Suspense, { fallback: null })
createElement(Suspense, { fallback: null, children: 'test' })
createElement(Suspense, { fallback: null }, 'test')

declare const Fragment: ESX.FragmentComponentType
const p4: ESX.ComponentProps<typeof Fragment> = {}

createElement(Fragment, null)
createElement(Fragment, { key: 'foo' })
createElement(Fragment, null, 'test', createElement(Suspense, { fallback: null }))

createElement('a', {
  href: 'test',
  onClick({ currentTarget }) {
    currentTarget.href
  }
})

declare const MemoTest: ESX.ExoticComponents.MemoComponent<typeof Test>
const memoProps: ESX.ComponentProps<typeof MemoTest> = {
  test: true
}

createElement(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
    }
  }
}
createElement(ForwardTest, {
  key: 'foo',
  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
}

function NoChild() {
  return null
}
function SingleChild(props: { children: ESX.SingleElementResult }) {
  return null
}
function TupleChild(props: { children: [string, string] }) {
  return true
}
function MultipleChild(props: { children: ESX.FragmentResult }) {
  return false
}
function NonStandardChild(props: { children(): null }) {
  return props.children()
}
function OptionalChild(props: { children?: ESX.SingleElementResult }) {
  return null
}

type NoChildType = ESX.ChildrenPropType<typeof NoChild>
type SingleChildType = ESX.ChildrenPropType<typeof SingleChild>
type TupleChildType = ESX.ChildrenPropType<typeof TupleChild>
type MultipleChildType = ESX.ChildrenPropType<typeof MultipleChild>
type NonStandardChildType = ESX.ChildrenPropType<typeof NonStandardChild>
type OptionalChildType = ESX.ChildrenPropType<typeof OptionalChild>

type NoChildTuple = ESX.ChildrenToTupleType<NoChildType>
type SingleChildTuple = ESX.ChildrenToTupleType<SingleChildType>
type TupleChildTuple = ESX.ChildrenToTupleType<TupleChildType>
type MultipleChildTuple = ESX.ChildrenToTupleType<MultipleChildType>
type NonStandardTuple = ESX.ChildrenToTupleType<NonStandardChildType>
type OptionalTuple = ESX.ChildrenToTupleType<OptionalChildType>

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

    const ChildrenPropName: 'children'
    type FragmentComponentType = ExoticComponents.ModeComponent<typeof ExoticComponents.Fragment>

    type ChildrenPropType<T extends Component> = 'children' extends keyof ComponentProps<T>
      ? ComponentProps<T> extends {
          [ChildrenPropName]?: infer C
        }
        ? C
        : never
      : never

    type ChildrenToTupleType<T> =
      | (// if T is itself never, make a 0-tuple
        Extract<T, any> extends never
          ? []
          : Exclude<T, ReadonlyArray<any>> extends never // ignore making a 1-tuple if there are no non-array elements
          ? never // make a 1-tuple which accepts the non-array children
          : [Exclude<T, ReadonlyArray<any>>])
      // if children is optional then make an empty tuple and also a 1-tuple with undefined
      | (undefined extends T ? [] | [undefined] : never)
      | (// if T is already an Array type (includes tuple types) we can preserve it
        T extends Array<any>
          ? T // if it is a ReadonlyArray (like FragmentResultArray), convert to an array
          : (T extends ReadonlyArray<infer C> ? C[] : never))

    interface IntrinsicAttributes<T extends Component> {
      key?: string
      ref?: (ComponentRefType<T> extends never ? never : React.Ref<ComponentRefType<T>>) | null
    }

    type ApparentComponentProps<T extends Component> =
      // TODO: skip the Pick if there is no keyof IntrinsicAttributes inside the ComponentProps
      | Pick<ComponentProps<T>, Exclude<keyof ComponentProps<T>, keyof IntrinsicAttributes<any>>> &
          IntrinsicAttributes<T>
      // also accept null as props if _all props_ are optional
      | ({} extends ComponentProps<T> & IntrinsicAttributes<T> ? null : never)

    type ApparentComponentPropsWithoutChildrenIntermediate<
      T extends Component
    > = typeof ChildrenPropName extends keyof ComponentProps<T>
      ? Pick<ComponentProps<T>, Exclude<keyof ComponentProps<T>, typeof ChildrenPropName>>
      : ComponentProps<T>
    type ApparentComponentPropsWithoutChildren<T extends Component> =
      // TODO: skip the Pick if there is no keyof IntrinsicAttributes inside the ComponentProps
      | Pick<
          ApparentComponentPropsWithoutChildrenIntermediate<T>,
          Exclude<
            keyof ApparentComponentPropsWithoutChildrenIntermediate<T>,
            keyof IntrinsicAttributes<any>
          >
        > &
          IntrinsicAttributes<T>
      // also accept null as props if _all props_ are optional
      | ({} extends ApparentComponentPropsWithoutChildrenIntermediate<T> & IntrinsicAttributes<T>
          ? null
          : never)

    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 ExoticComponentTypes
      ? ExoticComponents.ExoticComponentProps<T>
      : never

    type OrdinaryComponentAcceptsProps<
      T extends OrdinaryComponent,
      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)
      : never

    type ComponentAcceptsProps<T extends Component, P extends object> = T extends OrdinaryComponent
      ? OrdinaryComponentAcceptsProps<T, P>
      : ExoticComponents.ExoticComponentAcceptsProps<T, P>

    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 ExoticComponentSymbols = typeof ExoticComponents[keyof typeof ExoticComponents]
    type ExoticComponentTypes = {
      [S in ExoticComponentSymbols]: ExoticComponents.ExoticComponent<S, any>
    }[ExoticComponentSymbols]

    // 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 ExoticComponentSymbols> {
        $$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

      type ExoticComponent<
        S extends ExoticComponentSymbols,
        T extends S extends typeof Memo
          ? Component
          : S extends typeof ForwardRef
          ? ForwardComponentRender<any, any>
          : any
      > = S extends typeof Fragment | typeof ConcurrentMode | typeof StrictMode | typeof Suspense
        ? ModeComponent<S>
        : S extends typeof Memo
        ? MemoComponent<T>
        : S extends typeof ForwardRef
        ? ForwardComponent<T>
        : never

      type ExoticComponentAcceptsProps<SS extends ExoticComponentSymbols, P extends object> = {
        [S in SS]: S extends typeof Fragment | typeof ConcurrentMode | typeof StrictMode
          ? P extends ModeComponentProps
            ? ModeComponent<S>
            : never
          : S extends typeof Suspense
          ? P extends SuspenseComponentProps
            ? ModeComponent<S>
            : never
          : S extends typeof Memo
          ? MemoComponentWithProps<P>
          : S extends typeof ForwardRef
          ? ForwardComponentWithProps<P>
          : never
      }[SS]

      interface ModeComponentProps {
        [ChildrenPropName]?: FragmentResult
      }
      interface SuspenseComponentProps extends ModeComponentProps {
        fallback: FragmentResult
        maxDuration?: number
      }

      type ExoticComponentProps<T extends 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

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

      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
      }

      interface ForwardComponentWithProps<P extends object>
        extends ForwardComponent<ForwardComponentRender<P, any>> {}

      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
  }
}
ferdaber commented 5 years ago

Have you gotten parametric conditional types to work with checking against never? The last time I tried it always short circuits:

type IsNever<T> = T extends never ? true : false
type foo = IsNever<boolean> // false
type bar = IsNever<never> // never <-- shortcircuit

Asking because of this line:

    type ChildrenToTupleType<T> =
      | (// if T is itself never, make a 0-tuple
        Extract<T, any> extends never
          ? []
          : Exclude<T, ReadonlyArray<any>> extends never // ignore making a 1-tuple if there are no non-array elements
          ? never // make a 1-tuple which accepts the non-array children
          : [Exclude<T, ReadonlyArray<any>>])
Jessidhia commented 5 years ago

That's what I'm doing with the Extract<T, any>. The extra indirection seems to avoid the short circuiting.

ferdaber commented 5 years ago

Ah cool, I need some more time to grok this, I only wish I can leave inline comments on your comments πŸ˜„