remix: Incorrect type from `useLoaderData`

What version of Remix are you using?

1.6.7

Steps to Reproduce

export const loader = async () => {
  return {
    requestedDomains: [
      { domain: "example.com", status: "pending" },
      { domain: "saulgoodman.org", status: "approved" },
      { domain: "example.org", status: "rejected", reason: "Not allowed" },
    ],
  };
};

export default function Foo() {
  const { requestedDomains } = useLoaderData<typeof loader>();
  ...
}

Expected Behavior

the type of requestedDomains should be

SerializeObject<{
    domain: string;
    status: string;
    reason?: string;
}>[]

Actual Behavior

the type of requestedDomains is:

(SerializeObject<{
    domain: string;
    status: string;
    reason?: undefined;
}> | SerializeObject<{
    domain: string;
    status: string;
    reason: string;
}>)[]

See also this discussion where I try some solutions.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 19
  • Comments: 44 (8 by maintainers)

Most upvoted comments

For the remix tutorial, adding ‘unknown’ converstion seemed to make TS happy

  const { posts } = useLoaderData() as unknown as LoaderData;

Unfortunately I think we’re going to continue to get a lot of these weird edge cases. The issue is that Remix is trying to convert your actual data type, with all its various forms, and convert that to what the type would look like if you replaced any non-JSON types with their serialized versions.

They are using a home-grown function to do this, but plan to use Jsonify from type-fest. Even then, I’m not sure it it will every be 100% perfect.

I side-stepped this with remix-typedjson by simply using whatever type that TypeScript thinks the loader is returning. In order to do this, I also wrote my own JSON serialize/deserialize function (similar to superjson) to ensure that non-JSON values like Date, BigInt, etc. are converted back to their native values after parsing.

Can this be included in remix, types returned from remix are unusable. When using prisma, Remix Json: SerializeObject<UndefinedToOptional<Person>>[] Typed Json: Person[]

@kiliman Thanks!

useLoaderData<LoaderData>()

Yeah, I’m not a big fan of the SerializeObject type. That’s why I wrote remix-typedjson. It will automatically convert non-JSON types back into their native types, so you can use the actual types instead of the JSON-converted ones.

Don’t want to sound rude, but a feedback to the Remix Team - Facing the same issue and this has been frustrating! I had heard a lot about Remix but these kinds of errors while trying out its first tutorial makes me not want to use it for projects 😦

@sslgeorge the loader function can return only JSON values, remix needs to correctly convert non-JSON types in JSON compatible types, let me show you a simple example:

export const loader = () => {
  return {
    foo: new Date(),
  };
}

export default function App() {
  const { foo } = useLoaderData();
  return <>{foo.getFullYear()}</>;
}

This code is wrong, because the method getFullYear() does not exists on the String object.

But isn’t foo supposed to be a Date object? Yes, but you can’t send objects like Date, BigInt or classes over the wire, you need first to convert them in a format that can go over the wire. This format is JSON. The process to convert non json values to json values in this scenario is called network serialization.

This is why the SerializedObject type is required, is converts non-JSON types to JSON types.

For now typedJson seems to be the best alternative. But I’m still not sold on the idea it’s not handled by Remix itself.

I think the solution here is using SerializeFrom from @remix-run/server-runtime.

e.g.

export default function PostList({ posts }: { posts: SerializeFrom<Post[]>; }) {
  return (
    <div>
        Posts!
    </div>
  );
}

For the remix tutorial, adding ‘unknown’ converstion seemed to make TS happy

  const { posts } = useLoaderData() as unknown as LoaderData;

For other people’s reference, this is force casting: https://www.w3schools.com/typescript/typescript_casting.php

What would be the logical thing to do if done correctly without force casting? Should LoaderData type be updated or useLoaderData() method be updated. Ideally useLoaderData() should not have to go through unknown casting, so curious.

Another example:

import { useLoaderData } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";

export function loader() {
  const x = Math.random();

  if (x === 1) {
    return json({ a: "stringA" });
  }

  if (x === 2) {
    return json({ a: 2});
  }

  if (x === 3) {
    return json({ a: 2, b: 2 });
  }

  return json({ c: "stringC" });
}


export default function AppTest() {
  const loaderData = useLoaderData<typeof loader>();
  ...
}

const loaderData: SerializeObject<{
    a: string;
}> | SerializeObject<{
    a: number;
}> | SerializeObject<{
    c: string;
}>

b is never there.

Changing the type of a in the x === 3 case fixes the type

export function loader() {
  const x = Math.random();

  if (x === 1) {
    return json({ a: "stringA" });
  }

  if (x === 2) {
    return json({ a: 2});
  }

  if (x === 3) {
    return json({ a: [2], b: 2 });
  }

  return json({ c: "stringC" });
}


export default function AppTest() {
  const loaderData = useLoaderData<typeof loader>();
  ...
}

```ts
const loaderData: SerializeObject<{
    a: string;
}> | SerializeObject<{
    a: number;
}> | SerializeObject<{
    a: number[];
    b: number;
}> | SerializeObject<{
    c: string;
}>

The conclusion I was able to draw thus far is if an object is returned that has a property of the same type, but an extra property added as well, it doesn’t get picked up.

If I take the first example and completely remove the x === 2 case, the b property is picked up and the type if as follows:

export function loader() {
  const x = Math.random();

  if (x === 1) {
    return json({ a: "stringA" });
  }

//   if (x === 2) {
//     return json({ a: 2});
//   }

  if (x === 3) {
    return json({ a: 2, b: 2 });
  }

  return json({ c: "stringC" });
}


export default function AppTest() {
  const loaderData = useLoaderData<typeof loader>();
  ...
}
const loaderData: SerializeObject<{
    a: string;
}> | SerializeObject<{
    a: number;
    b: number;
}> | SerializeObject<{
    c: string;
}>

I confirm I have the same “bug” of TS types with the tutorial

🤖 Hello there,

We just published version 2.1.0-pre.0 which involves this issue. If you’d like to take it for a test run please try it out and let us know what you think!

Thanks!

Additionally, if you know the data types your are using are supported by @kiliman 's remix-typedjson that can also be a great choice, but know that remix-typedjson does not attempt to account for any serialization/deserialization outside of its supported types

As of v0.2.0, remix-typedjson lets you register a custom type handler, so you can handle any type your app uses.

https://github.com/kiliman/remix-typedjson#registercustomtype

Nice! 💪

That still requires a manual step to register, which if you are using 3rd party types might be onerous. I think its a good tradeoff and the right design choice for remix-typedjson, but just mentioning it for others who might want some further nuance on tradeoffs.

So a lot has passed since I opened this issue and I read all your comments.

The snippet I shared at the start was a simplified version of a loader I actually have in a project for Shopify, in this time I enhanced the project, updating also remix to 1.14.1, so I would like to share with you some of my thoughts.

For this, take the following loader for an embedded Shopify app:

export const loader = async ({ request }: LoaderArgs) => {
  const url = new URL(request.url);
  const shopifyDomain = url.searchParams.get("shop");
  const host = url.searchParams.get("host");

  if (shopifyDomain && host) {
    const session = await shopSession.getSession(request.headers.get("Cookie"));
    if (session.get("shopifyDomain") === shopifyDomain) {
      const shop = await findShopInAuth({ shopifyDomain });
      if (shop) {
        const apiKey = Env.get("SHOPIFY_API_KEY");
        return {
          success: true,
          apiKey,
          host,
        };
      }
    }

    throw redirect(`/api/auth?shop=${shopifyDomain}&host=${host}`);
  }

  return {
    success: false,
  };
};

The return type of this loader is

Promise<{
    success: boolean;
    apiKey: string;
    host: string;
} | {
    success: boolean;
    apiKey?: undefined;
    host?: undefined;
}>

Please, keep in mind that this is just how typescript infer the return type.

So you can’t correctly discriminate this union:

export default function EmbeddedLayout() {
  const loaderData = useLoaderData<typeof loader>();
  const locale = useLocale();

  const { ready } = useTranslation("translation", { useSuspense: false });

  if (ready) {
    return loaderData.success ? (
      <EmbeddedApp {...loaderData}> // error
        <Outlet />
      </EmbeddedApp>
    ) : (
      <AppProvider i18n={locale} linkComponent={Link}>
        <Outlet />
      </AppProvider>
    );
  }

  return <></>;
}

type EmbeddedAppProps = React.PropsWithChildren<{
  apiKey: string;
  host: string;
}>;

declare function EmbeddedApp({ children, apiKey, host }: EmbeddedAppProps);

This raise an error since loaderData is of type:

SerializeObject<UndefinedToOptional<{
    success: boolean;
    apiKey: string;
    host: string;
}>> | SerializeObject<UndefinedToOptional<{
    success: boolean;
    apiKey?: undefined;
    host?: undefined;
}>>

So, should we use a LoaderData type? Well, you could:

type LoaderData =
  | { success: true; apiKey: string; host: string }
  | { success: false };

export default function EmbeddedLayout() {
  const loaderData = useLoaderData<LoaderData>();
  const locale = useLocale();

  const { ready } = useTranslation("translation", { useSuspense: false });

  if (ready) {
    return loaderData.success ? (
      <EmbeddedApp {...loaderData}> // all good
        <Outlet />
      </EmbeddedApp>
    ) : (
      <AppProvider i18n={locale} linkComponent={Link}>
        <Outlet />
      </AppProvider>
    );
  }

  return <></>;
}

but from my little experience I don’t see this approach scalable, since if the loader has to return another object, you should also update the LoaderData type, otherwise you will get some errors.

I don’t like this approach, so instead I started to use as const on returns:

return {
  success: true,
  apiKey,
  host,
} as const;

...

return {
  success: false,
} as const;

From this typescript infer the following type:

Promise<{
    readonly success: true;
    readonly apiKey: string;
    readonly host: string;
} | {
    readonly success: false;
    readonly apiKey?: undefined;
    readonly host?: undefined;
}>

That is a truly discriminated union, and now loaderData is

SerializeObject<UndefinedToOptional<{
    readonly success: true;
    readonly apiKey: string;
    readonly host: string;
}>> | SerializeObject<UndefinedToOptional<{
    readonly success: false;
    readonly apiKey?: undefined;
    readonly host?: undefined;
}>>

This works great, no force casting with unknown and no LoaderData type.

A little enhancement from Remix would be to remove from the type keys with undefined as value, since they won’t exists in the returned json, but I think it is ok now.

@miniplus this could also fix your issue, however I don’t know why you never see b, I use typescript 4.9.5, and the following typescript works:

function loader() {
  const x = Math.random();

  if (x === 1) {
    return { a: "stringA" } as const;
  }

  if (x === 2) {
    return { a: 2 } as const;
  }

  if (x === 3) {
    return { a: 2, b: 2 } as const;
  }

  return { c: "stringC" } as const;
}

export type Prettify<T> = { [K in keyof T]: T[K] } & {};

type RemoveUndefined<T> = Prettify<{ -readonly [K in keyof T as T[K] extends undefined ? never : K]: T[K]}>

const bar: RemoveUndefined<ReturnType<typeof loader>> = loader();

The type of bar is:

{
    a: "stringA";
} | {
    a: 2;
} | {
    a: 2;
    b: 2;
} | {
    c: "stringC";
}

Here is a playground

@uhrohraggy from the type you posted, user should be User | null, and not User | undefined, null will be correctly serialized into the json, undefined won’t

Is there any update on whether a solution akin to remix-typedjson will be introduced within Remix?

+1 this is definitely confusing and not clear how best to handle. I would fully expect to be able to do this:

const { user, notes}: { user: User | undefined, notes: Note[]} = useLoaderData<typeof loader>();
// user is type User | undefined
// notes is type Note[]

given a loader function akin to,

export async function loader({ request }: LoaderArgs) {
  const user = await useOptionalUser();
  const notes = user ? await getNotes({ userId: user.id }) : ([] as Note[]);
  return json({ user, notes });
}

quick edit: thinking about this some more, perhaps a prisma-client model could automatically provide a toJSON() serializer …perhaps it’s not json’s job to handle this, but having it in the indie-stack example makes it confusing when slight adjustments leads to the SerializeObject<UndefinedToOptional<Note>>[] issue.