supabase / supabase-js

An isomorphic Javascript client for Supabase. Query your Supabase database, subscribe to realtime events, upload and download files, browse typescript examples, invoke postgres functions via rpc, invoke supabase edge functions, query pgvector.
https://supabase.com
MIT License
3.11k stars 247 forks source link

Incorrect handling of union types in `PostgrestResponseSuccess<T>` type #688

Closed leohku closed 1 year ago

leohku commented 1 year ago

Bug report

Describe the bug

await supabase.from(tableName).select("*") returns a type PostgrestResponse<T>, which could be of type PostgrestResponseSuccess<T>.

Currently PostgrestResponseSuccess<T> is defined as:

interface PostgrestResponseSuccess<T> extends PostgrestResponseBase {
    error: null;
    data: T[];
    count: number | null;
}

Note that the generic type T above has no handling of union types. For example, for PostgrestResponseSuccess<A | B>, data is of type (A | B)[], when it should be of type A[] | B[].

This is problematic because (A | B)[], an array that contains both type A and B entries, isn't a valid return type for a database SELECT query, while the correct type, A[] | B[], generates a type error.

To Reproduce

To illustrate, consider a generic useTable hook which takes in tableName and returns a Tanstack Query useQuery hook that loads a table from Supabase.

import { useQuery } from "@tanstack/react-query";
import { useSupabaseClient } from "@supabase/auth-helpers-react";
import { PostgrestError } from "@supabase/supabase-js";

// Database types
interface Research {
    id: string;
    research: string;
}

interface Link {
    id: string;
    link: string;
}

interface Result {
    id: string;
    result: string;
}

type TableName = "researches" | "links" | "results";

type Response<TableName> = {
    data:
        | (TableName extends "researches"
              ? Research[]
              : TableName extends "links"
              ? Link[]
              : TableName extends "results"
              ? Result[]
              : any[])
        | null;
    error: PostgrestError | null;
};

export default function useTable(tableName: TableName) {
    const supabase = useSupabaseClient();

    const { isLoading, data } = useQuery({
        queryKey: [tableName],
        queryFn: async () => {
            /*    Type error here!
                  vvvvvvvvvvvvvvv  */
            const { data, error }: Response<TableName> = await supabase
                .from(tableName)
                .select("*");
            if (error) throw error;
            return data;
        },
    });

    return { isLoading, data };
}

The full type error generated is as follows:

Type 'PostgrestResponse<Research | Link | Result>' is not assignable to type 'Response<TableName>'.
  Type 'PostgrestResponseSuccess<Research | Link | Result>' is not assignable to type 'Response<TableName>'.
    Types of property 'data' are incompatible.
      Type '(Research | Link | Result)[]' is not assignable to type 'Research[] | Link[] | Result[] | null'.
        Type '(Research | Link | Result)[]' is not assignable to type 'Research[]'.
          Type 'Research | Link | Result' is not assignable to type 'Research'.
            Property 'research' is missing in type 'Link' but required in type 'Research'.

Expected behaviour

In a nutshell, our supplied Response<TableName> generates the correct type, Research[] | Link[] | Result[] | null, which is what we expect returned from the database.

However, Typescript tries to assign it to (Research | Link | Result)[], an array that mixes Research, Link, and Result entries. This should, in no circumstance, be returned from the database. Hence, the type handling here is incorrect.

The fix

The fix is to allow the generic type T of PostgrestResponseSuccess<T> to be transformed into a distributive type if T is a union type. This can be implemented by adding a type ToArray<T>, as follows:

type ToArray<T> = T extends any ? T[] : never;

interface PostgrestResponseSuccess<T> extends PostgrestResponseBase {
    error: null;
    data: ToArray<T>;
    count: number | null;
}

With the fix in place, PostgrestResponseSuccess<Research | Link | Result> can be assigned correctly to Response<TableName>.

References

Distributive Conditional Types: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types

leohku commented 1 year ago

Whoops, submitted to the wrong repo - should be https://github.com/supabase/postgrest-js/issues instead.