kiliman / remix-typedjson

This package is a replacement for superjson to use in your Remix app. It handles a subset of types that `superjson` supports, but is faster and smaller.
MIT License
435 stars 19 forks source link

`useTypedFetcher` make TS complain about incompatible types #27

Open kwiat1990 opened 1 year ago

kwiat1990 commented 1 year ago

I use the packages all over the place in my codebase and today I wanted to take advantage of typed fetcher. I use Remix Validated Form for all forms. It gives a possibility to pass a fetcher instance to a form. With the default useFetcher from Remix there is no problem but as soon as I change it to useTypedFetcher Typescript starts to complain about incompatibilities between types.

const fetcher = useTypedFetcher<SearchResponse | ErrorResponse>();
// with standard useFetcher there's no problem
// const fetcher = useFetcher<SearchResponse | ErrorResponse>(); 

<ValidatedForm
  action={"/api/search"}
  fetcher={fetcher}
  noValidate
  validator={validator}
  >
...
</ValidatedForm>

Error message:

Type 'TypedFetcherWithComponents<SearchResponse | ErrorResponse>' is not assignable to type 'FetcherWithComponents<any> | undefined'.
  Type 'TypedFetcherWithComponents<SearchResponse | ErrorResponse>' is not assignable to type '{ state: "idle"; type: "done"; formMethod: undefined; formAction: undefined; formData: undefined; formEncType: undefined; submission: undefined; data: any; } & { Form: ForwardRefExoticComponent<FormProps & RefAttributes<...>>; submit: SubmitFunction; load: (href: string) => void; }'.
    Type 'TypedFetcherWithComponents<SearchResponse | ErrorResponse>' is not assignable to type '{ state: "idle"; type: "done"; formMethod: undefined; formAction: undefined; formData: undefined; formEncType: undefined; submission: undefined; data: any; }'.
      Types of property 'state' are incompatible.
        Type '"idle" | "loading" | "submitting"' is not assignable to type '"idle"'.
          Type '"loading"' is not assignable to type '"idle"'.ts(2322)
index.d.ts(184, 5): The expected type comes from property 'fetcher' which is declared here on type 'IntrinsicAttributes & { validator: Validator<{ q?: string | undefined; }>; onSubmit?: ((data: { q?: string | undefined; }, event: FormEvent<HTMLFormElement>) => void | Promise<...>) | undefined; ... 5 more ...; disableFocusOnError?: boolean | undefined; } & Omit<...>'

It's unclear for me if this is something I can/should fix on my own or is related to Remix Validated Form or rather to remix-typedjson?

kiliman commented 1 year ago

All the functions in remix-typedjson expects the generic type to be the return type of either a loader or action. This is so it can unwrap the promise and infer the value returned from the json function.

Is there a reason you're using <SearchResponse | ErrorResponse> instead of <typeof loader>?

kwiat1990 commented 1 year ago

All the functions in remix-typedjson expects the generic type to be the return type of either a loader or action. This is so it can unwrap the promise and infer the value returned from the json function.

Is there a reason you're using <SearchResponse | ErrorResponse> instead of <typeof loader>?

I didn't use this convention to expect a loader type in a component. I rather returned form loader a specific type, which I also expect in a component. In this case SearchResponse | ErrorResponse. But I have tried to import a loader from my API route (import { loader as searchLoader } from "~/routes/api.search") and used it in fetcher instead. This approach doesn't fix the issue though:

Type 'TypedFetcherWithComponents<({ request }: DataFunctionArgs) => Promise<TypedJsonResponse<ErrorResponse> | TypedJsonResponse<SearchResults>>>' is not assignable to type 'FetcherWithComponents<any> | undefined'.
  Type 'TypedFetcherWithComponents<({ request }: DataFunctionArgs) => Promise<TypedJsonResponse<ErrorResponse> | TypedJsonResponse<SearchResults>>>' is not assignable to type '{ state: "idle"; type: "done"; formMethod: undefined; formAction: undefined; formData: undefined; formEncType: undefined; submission: undefined; data: any; } & { Form: ForwardRefExoticComponent<FormProps & RefAttributes<...>>; submit: SubmitFunction; load: (href: string) => void; }'.
    Type 'TypedFetcherWithComponents<({ request }: DataFunctionArgs) => Promise<TypedJsonResponse<ErrorResponse> | TypedJsonResponse<SearchResults>>>' is not assignable to type '{ state: "idle"; type: "done"; formMethod: undefined; formAction: undefined; formData: undefined; formEncType: undefined; submission: undefined; data: any; }'.
      Types of property 'state' are incompatible.
        Type '"idle" | "loading" | "submitting"' is not assignable to type '"idle"'.
          Type '"loading"' is not assignable to type '"idle"'.ts(2322)
index.d.ts(184, 5): The expected type comes from property 'fetcher' which is declared here on type 'IntrinsicAttributes & { validator: Validator<{ q?: string | undefined; }>; onSubmit?: ((data: { q?: string | undefined; }, event: FormEvent<HTMLFormElement>) => void | Promise<...>) | undefined; ... 5 more ...; disableFocusOnError?: boolean | undefined; } & Omit<...>'
kiliman commented 1 year ago

Remember, your loaders are not returning a standard JavaScript object. It's returning a Response with a JSON payload. The typeof loader is doing some TypeScript magic to unwrap all that to get at your actual result.

Please provide a full example of what you're trying to do. Here's a sample that works.

import { type LoaderArgs } from '@remix-run/node';
import { typedjson, useTypedFetcher } from 'remix-typedjson';

type SuccessResponse = {
  success: true;
  results: string[];
};

type ErrorResponse = {
  success: false;
  error: string;
};

export async function loader({ request }: LoaderArgs) {
  const { searchParams } = new URL(request.url);
  if (!searchParams.has('query')) {
    const result: ErrorResponse = {
      success: false,
      error: 'No query provided',
    };
    return typedjson(result);
  }

  const result: SuccessResponse = {
    success: true,
    results: ['a', 'b', 'c'],
  };
  return typedjson(result);
}

export default function Component() {
  const fetcher = useTypedFetcher<typeof loader>();
  if (fetcher.data) {
    if (fetcher.data.success) {
      console.log(fetcher.data.results);
    } else {
      console.error(fetcher.data.error);
    }
  }
}

image

kwiat1990 commented 1 year ago

The a bit simplified version of my code is available as gist here. In my case fetcher.data doesn't seem to be typed at all. The fetcher itself holds following type: const fetcher: TypedFetcherWithComponents<({ request }: DataFunctionArgs) => Promise<TypedJsonResponse<ErrorResponse> | TypedJsonResponse<SearchResults>>>.

Accessing hits from successful response yields (and so on for other fields):

Property 'hits' does not exist on type 'ErrorResponse | SearchResults'.
  Property 'hits' does not exist on type 'ErrorResponse'.`
kiliman commented 1 year ago

Hmm.. it's working for me

import type { LoaderArgs } from '@remix-run/node';
import { typedjson, useTypedFetcher } from 'remix-typedjson';

interface ErrorResponse {
  message: string;
}

interface SearchResults {
  query: string;
  hits: string[];
}

export async function loader({ request }: LoaderArgs) {
  const url = new URL(request.url);
  const searchTerm = url.searchParams.get('q');

  if (!searchTerm) {
    return typedjson<ErrorResponse>({ message: 'No query provided' }, 400);
  }

  try {
    // let's pretend it's a result of a fetch call
    const result: SearchResults = {
      query: 'lorem ipsum',
      hits: [],
    };
    return typedjson(result);
  } catch (e) {
    return typedjson<ErrorResponse>(
      { message: 'Ooops, something went wrong.' },
      { status: 500 },
    );
  }
}

export default function Component() {
  const fetcher = useTypedFetcher<typeof loader>();
  return (
    <div>
      {fetcher.data && 'message' in fetcher.data && (
        <p>{fetcher.data?.message}</p>
      )}
      {fetcher.data && 'hits' in fetcher.data && (
        <>
          {fetcher.data.hits.map((hit, index) => (      
            <p key={index}>{hit}</p>
          ))}
        </>
      )}
    </div>
  );
}

image

kwiat1990 commented 1 year ago

Hm, it seems to work in template but if fetcher.data is accessed below definition of fetcher const, Typescript will complain with the mentioned error:

if (fetcher.data) {
  if (fetcher.data.hits) {
    console.log(fetcher.data.hits);
  }
}

It also doesn't solve the problem of passing fetcher to ValidatedForm. You should also be able to see this error, don't you?

kiliman commented 1 year ago
if (fetcher.data) {
  if (fetcher.data.hits) {
    console.log(fetcher.data.hits);
  }
}

You haven't narrowed the type, so TS can't resolve data.hits. In my example, I used a type discriminator: success: true | false. Your other sample had 'hits' in fetcher.data

As for passing fetcher to <ValidatedForm/>, I imagine it's expecting a standard fetcher and not one from typedjson. You'll probably need to use as FetcherWithComponents<T>