Preserving generic types through another generic type in TypeScript

Viewed 450

I'm considering submitting a patch (or patches) of the type declarations in @testing-library/react and/or @testing-library/dom. Currently, all queries on RenderResult return variants of HTMLElement, which leaves me writing a lot of code like

const myDiv = wrapper.getByText('some text') as HTMLDivElement;

The simplest solution is to make the queries generic.

export type GetByText = <T extends HTMLElement = HTMLElement>(/* ... */) => T
export const getByText: GetByText

Then I can just do:

const myDiv = wrapper.getByText<HTMLDivElement>('some text');

While this works in TS Playground, something in @testing-library/react is preventing the generic form from being passed along to RenderResult's methods.

These are the relevant snippets of the current versions of @testing-library/react and @testing-library/dom.

@testing-library/dom

types/queries.d.ts

export type GetByText = (
  container: HTMLElement,
  id: Matcher,
  options?: SelectorMatcherOptions,
) => HTMLElement

export const getByText: GetByText

types/get-queries-for-element.d.ts

import * as queries from './queries';

export type BoundFunction<T> = T extends (
    attribute: string,
    element: HTMLElement,
    text: infer P,
    options: infer Q,
) => infer R
    ? (text: P, options?: Q) => R
    : T extends (a1: any, text: infer P, options: infer Q, waitForElementOptions: infer W) => infer R
    ? (text: P, options?: Q, waitForElementOptions?: W) => R
    : T extends (a1: any, text: infer P, options: infer Q) => infer R
    ? (text: P, options?: Q) => R
    : never;
export type BoundFunctions<T> = { [P in keyof T]: BoundFunction<T[P]> };

export type Query = (
    container: HTMLElement,
    ...args: any[]
) => Error | Promise<HTMLElement[]> | Promise<HTMLElement> | HTMLElement[] | HTMLElement | null;

export interface Queries {
    [T: string]: Query;
}

export function getQueriesForElement<T extends Queries = typeof queries>(
    element: HTMLElement,
    queriesToBind?: T,
): BoundFunctions<T>;

@testing-library/react

(may or may not be relevant)
types/index.d.ts

export type RenderResult<
  Q extends Queries = typeof queries,
  Container extends Element | DocumentFragment = HTMLElement
> = {
  // ...
} & {[P in keyof Q]: BoundFunction<Q[P]>}

Alongside the change I made above, making each query's type generic, I changed Query in get-queries-for-element.d.ts:

export type Query = <T extends HTMLElement>(
    container: HTMLElement,
    ...args: any[]
) => Error | Promise<T[]> | Promise<T> | T[] | T | null;

I'm nearly certain the issue is in BoundFunction or getQueriesForElement(), as when I made these changes (which I am like 37% confident are right), I get an error:

Type 'typeof import(".../node_modules/@testing-library/dom/types/queries")' does not satisfy the constraint 'Queries'.
  Property 'getByLabelText' is incompatible with index signature.
    Type 'GetByText' is not assignable to type 'Query'.
      Type 'HTMLElement' is not assignable to type 'T | Error | T[] | Promise<T[]> | Promise<T>'.
        Type 'HTMLElement' is not assignable to type 'T'.
          'HTMLElement' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'HTMLElement'. ts(2344)

There might be another problem in @testing-library/react (and thus, the other testing-library packages), but fixing this is the first step.

Part of my problem is that I don't actually understand what BoundFunction is doing. What do I need to do to make make typeof queries satisfy the Queries constraint?

TS Playground

1 Answers

I'm pretty sure this is a design limitation of TypeScript, although I haven't found a narrowly applicable issue in GitHub. The general issue is that the heuristics TypeScript uses to check assignability between two types do not perform true unification. See microsoft/TypeScript#30134 for a discussion about the possibility of implementing such unification in TypeScript.

The shortest example I can come up with of the problem you're facing is this:

declare const genFn: <T extends 0>() => T;
const widerGenFn: <T extends 0>() => T | 1 = genFn; // error!
// Type '<T extends 0>() => T' is not assignable to type '<T extends 0>() => T | 1'.
// Type '0' is not assignable to type 'T | 1'

The function genFn() has a type parameter T that is constrained to the numeric literal type 0, takes no function parameters, and returns a value of type T. The function widerGenFn() is exactly the same, except that it returns a value of type T | 1.

If someone replaced widerGenFn with genFn behind your back, you should not notice; if you call widerGenFn<ABC>(), you will expect a value of type ABC | 1 back, and the fact that genFn returns a value of type ABC won't bother you, because every value of type ABC is also a value of type ABC | 1. This implies that the type of genFn should be assignable to the type of widerGenFn.

But it's not. TypeScript complains that Type '<T extends 0>() => T' is not assignable to type '<T extends 0>() => T | 1', because Type '0' is not assignable to type 'T | 1'. What gives?

Well, when comparing generic functions like this, the compiler does not use so-called "higher kinded types" as requested in microsoft/TypeScript#1213. It seems to immediately substitute a specific type for T in the target type. So for genFn, the compiler changes <T extends 0>() => T and just replaces T with its constraint, 0. So genFn is seen as being type () => 0, which is not assignable to <T extends 0>() => T | 1.

And there's an error.

This means you also get errors when trying to use generic parameter defaults, since type X<T extends U = V> will require that V extends U:

type GenFn = typeof genFn;
type WiderGenFn = typeof widerGenFn
type Foo<T extends WiderGenFn = GenFn> = void; // error!
// ---------------------------> ~~~~~

So what's the workaround? I'm not sure... it probably will depend strongly on the use cases. One way is to "widen" the constraint to the union of both types involved:

type Bar<T extends WiderGenFn | GenFn = GenFn> = void; // okay

(in your case this would look like Q extends Queries | typeof queries = typeof queries)

There may be other ways to sidestep the lack of unification in TypeScript, but I wouldn't be surprised to see strange side effects and edge cases pop up.

Playground link to code

Related