-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Made LoaderFunction, MetaFunction and ActionFunction generic #1254
Made LoaderFunction, MetaFunction and ActionFunction generic #1254
Conversation
@mattpocock I came to the PRs looking for a PR like this one. But seems you need to add yourself to the contributors.yml to "sign the CLA" per https://remix.run/docs/en/v1/pages/contributing (which is worded confusingly, but I see other PRs marked "CLA: signed" when they also add themselves to contributors.yml in the PR) |
Action and loader can't be generic. They return People are usually wanting to do this so they can get type inference between let res = await fetch("https://example.com/api");
let json = await res.json(); // <-- this can't be generic
// the function lives on the server, it's not called by this code
// so this code can't infer or know anything about it.
To maybe help with this idea a little more (sorry this comes up a lot so I'm trying to answer for the last time here 😅), let's pretend ... await readFileToJSON("/some/file.json"); What does that function look like? async function readFileToJSON(path) {
let file = await fs.readFile(path);
return JSON.parse(file);
} TypeScript can't have any idea what is in that file. That's essentially what Again, consider instead of server responses and fetches from the browser, we built a framework for writing and reading files to the disk: // pretend remix loaders actually wrote JSON files to disk
export function writeFileToJSON() {
return { lol: true }
}
// and pretend useLoaderData read those files:
export default function Thing() {
let data = useFileData();
} Because they're in the same file they feel like TypeScript can infer stuff ... and maybe we can ... but that inference is still just a convenient guess, it's not real type safety. If you read from disk, even if you just barely wrote the file to disk in the same module, you can't really know what's in there when you read it again. It's invisible to static analysis. Type safety comes from static analysis. Generics like the ones in this PR (and our own questionable generic to Additionally, you often need to return headers in your response or different status codes and the generics can't participate in that at all: export function loader() {
return json(data, {
status: 401,
{ headers: { "Server-Timing": serverTiming } }
});
} Let's say you somehow solve all of this, now you've got the serialization problem. Sending a export function loader() {
return json({ date: new Date() });
}
let { date } = useLoaderData();
// it's a string! It's not just dates either, stuff like FaunaDB return objects with custom methods on them from queries. So then you have custom parse/serialization needs, too. And then after you solve that, now you've got React context and TypeScript's limitations on module APIs in the way. // typescript doesn't let you type a module (afaik) so we can't infer
// this return value and put it somewhere for ...
export function loader() {
return something
}
export default function Route() {
// ... this hook to infer. Now if you solve the TypeScript problem,
// every call to `useLoaderData` will have different return types
// depending on the route it was called in. Now mix in calling it
// from a different file (in an abstraction) and there's just no way
// to know what each useLoaderData call should infer.
let data = useLoaderData();
} Our Recommendation:Our recommendation for type safety is to use the export let loader: LoaderFunction = async () => {
// this will give you type safety that you returned the right
// shape of data, while still having control over status/headers, etc.
return json<YourType>(data, {
status: 401,
{ headers: { "Server-Timing": serverTiming } }
});
}
export default function RouteComp() {
// correct 99.999% of the time, but possibly a lie if the network
// falls apart or your reconfigure your CDN and it botches the
// response from the loader, but it's okay because Error Boundary will
// render if this object is the wrong shape
let data = useLoaderData<YourType>();
} |
@mattpocock you may find this helpful https://www.npmjs.com/package/remix-superloader#advanced-loaderfunction-type |
Creator of Zod/tRPC here :) I'd love to see something like this PR happen in Remix, and I'm not sure I understand the reasoning against it, I'm afraid. I'll try to discuss some of the main points in order.
lol sorry 😅
By making direct inference from a) manually write types for all request payloads and pass into The first option isn't DRY. I'd have to change both
Every TypeScript type is a potential lie! There's nothing special about sending data over the wire that makes these types especially untrustable. Anything can happen to your in-memory data structures too, and TypeScript can't do anything to stop it. let object = {
name: "frodo",
};
(object as any).name = 5;
object.name; // => 5 Types only exist as a development-time convenience and are essentially "made up". But it doesn't matter, because the lies are useful and we can generally trust that they'll be true. Part of the appeal of a fullstack TypeScript framework is that its easy to share our made-up types across the client-server divide.
This is another line in the sand that confuses me. You've already provided an escape hatch by making So isn't this battle already lost, in some sense? You've provided the ingredients needed to make inference possible, but its still unnecessarily difficult. There's already a desire path here, so it really seems like the right move to streamline this DX. API
Yeah the API you're critiquing there is impossible I think. Here's my proposal from twitter import {json, makeLoader} from "@remix-run/node";
import {useLoaderData} from "@remix-run/react";
export const loader = makeLoader(async ctx => {
return json({name: "Colin"});
});
export default function Page() {
const person = useLoaderData<typeof loader>();
return <p>Hello {person.name}</p>;
} Nitty gritty
True, generics are like a virus. You'd have to genericify things all the way down.
The "raw" return type of
I'd only expect to be able to return JSON-serializable stuff from a loader personally. I think this is expected and decently easy to explain to users. Next.js users will also be accustomed to this already. In fact, you could statically prevent users from trying to return non-serializable data with something like this (regardless of whether you change your mind on the generics stuff): type Scalar = string | number | boolean | null | undefined;
type JSON = Scalar | { [key: string]: JSON } | JSON[]
function json(arg: JSON) { ... } |
I think my trouble with all of this is that loaders are sometimes called directly, especially in unit tests. As I understand it, what you're proposing only works when coupled with What happens when I make component loader functions and call them in my route loaders? // components/user-profile.tsx
export let loader: LoaderFunction = async () => {
// ...
return json(...);
} // routes/users/$userId.tsx
import { UserProfile, userLoader as loader } from "~/user-profile"
export let loader: LoaderFunction = async (ctx) => {
// What is the type of `res`? It's not unwrapped like in useLoaderData
let res = await userLoader(ctx);
// This is a TS error right? The type thinks it's unwrapped?
let user = await res.json();
return json(user);
};
export default function User() {
let user = userLoaderData<typeof loader>();
// this isn't really the `typeof loader` it's an unwrapped version of
// a loader because of Remix's specific behavior of unwrapping it
} Additionally, it's common to unit test loaders by calling them directly, what happens to the types there? import { loader } from "~/routes/some-route.tsx"
it("...", async () => {
let request = new Request("...");
let res = await loader({ request, params: {}, context: {} });
// this is a TS error right? The type thinks it's unwrapped?
let data = await res.json();
expect(data).toEqual("...");
}); My TypeScript skills are very basic and there's a good chance I'm missing something you're saying. Let me know if I'm missing something here. I do recognize that Remix isn't completely bound to "well, Response isn't generic so loaders can't be either". Remix has specific behaviors we can plan on between It seems like what we're really after is a way to get the unwrapped version of a loader for let data = useLoaderData(Unwrapped<loader>); |
@ryanflorence @colinhacks A proposal to solve this: https://www.loom.com/share/13109c44002e4061b75a91e994d3d8bf TL;DW: You can add a generic to TS playground: |
Interesting. I think I'm after all of this // note it's `async`
const loader: LoaderFunction = async ({ request }) => {
return json({ greatData: {} })
}
// nice for route components
const data = useLoaderData<InferLoaderData<typeof loader>>();
data.greatData;
// json returns normal responses, no "remixisms"
const res = json({ beef: "lol"})
const data = await res.json();
// can call loaders directly from other loaders or tests
const res = await loader({ params: {}, context: {}, request: new Request("/fake")})
const data = res.json(); |
All of that is certainly possible. Away from computer watching football, will post a POC tomorrow. Do you want the res.json to be strongly typed in the returned response when reusing a loader? |
Here you go:
|
@mattpocock Wouldn't it be possible to somehow still have a |
@MichaelDeBoey Sure, but you wouldn't be able to do any inference here based on the return type, you'd need to provide it up front: const loader: LoaderFunction<{ id: string }> = () => {
// Must return a `Response` with `json({ id: '' })`
} Which is cool in itself, but perhaps a separate discussion. |
@mattpocock So there's no solution to both infer the type + make it strict? 🤔 |
If you're using the TS is proposing a |
@ryanflorence thanks for the response. Sorry for the delay here - been at a conference all week. As @mattpocock mentioned, making // declarations.d.ts
interface Body<T> {
readonly body: ReadableStream<Uint8Array> | null;
readonly bodyUsed: boolean;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
formData(): Promise<FormData>;
json(): Promise<T>;
text(): Promise<string>;
}
interface Response<T> extends Body<T> {
readonly headers: Headers;
readonly ok: boolean;
readonly redirected: boolean;
readonly status: number;
readonly statusText: string;
readonly type: ResponseType;
readonly url: string;
clone(): Response<T>;
} Then you can make // make `json` generic
export declare type JsonFunction = <T>(
data: T,
init?: number | ResponseInit
) => Response<T>; With this change, the // inferred
json({hello: 'world'}); // Response<{hello: string}>;
// enforced
json<string>(5); // TypeError: number is not assignable to type `string` You can call loaders from other loaders, and they'll return // route.ts
export async function loader(args: DataFunctionArgs) {
return json({hello: 'world'});
}
// outerroute.ts
import {loader} from './route';
export async function loader(args: DataFunctionArgs) {
// unwrapping Request
const res = await userLoader(args);
const user = await res.json();
// {hello: string}
return json(user);
// directly returning result of another loader
return userLoader(ctx);
} As Matt suggested, Remix could also provide a utility called type ExtractResponseType<T> = T extends Response<infer U> ? U : T;
type InferLoaderType = ExtractResponseType<Awaited<ReturnType<typeof loader>>>; This is the bare minimum that I'd recommend in terms of paving the way for type inference, but I still think there room for improvement in the DX. Most of this was in my original API proposal but I didn't justify those decisions explicitly, so here goes. Alias for
|
I'll also be at Remix Conf! We should have a summit. |
Unfortunately your |
True. But since this would be a first-party HOF, we could consider using the |
Can this be fixed with a |
I think that the Response with generic approach described above by myself and Colin is the most idiomatic. I think that having makeLoader muddies the waters on what gets omitted and what doesn't, so I think a type-only solution is preferable. |
I agree, I shouldn’t have coupled Note that a straight genericification of Response appears to be impossible - I tried every version in my power but it’s not possible to override |
@colinhacks do you mind writing out the idea you shared with me at Remix Conf? I'm mostly interested in what the API looks like. We can talk about implementation later. Here are the things that are important to me:
I think those are the most important bits. If we can handle that then I feel much better about this. |
From my understanding, serializable types should be allowed - but they should be deserialized at the type level so that Date becomes string. This reflects Remix's behaviour. |
@mattpocock I'm not sure what that's supposed to look like. I believe @kentcdodds wants to ensure that return json({ date: new Date() }) // this should complain, as type is not compatible with useLoaderData
const { date } = useLoaderData<typeof loader>()
console.log(date.toLocaleDateString()) // TS is lying as date is really a string, not Date type If |
@kiliman This is what I think he means: type Serialized<T> = {/* ts magic */}
type Input = { date: Date }
type Output = Serialized<Input> // { date: string } This would allow passing other certain types where their serialized forms are known, and they would show up as that type in the Although I don't think it's a huge ask for users to manually do |
Agreed. Here's my adjusted requirements:
|
Ah, ok. Although I find it odd that the return type of the loader wouldn't match the return type of |
@kiliman the types would have to be a lie because when the date is sent over fetch it’s serialized to a string and remix doesn’t convert those back to date objects. That’s why I wrote https://www.npmjs.com/package/remix-superloader which basically does everything folks in this thread are asking for BUT adds a bit of overhead. Edit: just reread the thread and I see you already know that. I’ll see myself out |
@kiliman I agree, but it's important for the types not to lie to users. |
Yes and that's the reason I suggest restricting json parameter be JSON values so there would be no chance to lie. If they want to send a date they should convert it to a string explicitly instead of relying on JSON.stringify. |
@kiliman I disagree, the types should reflect what is actually allowed by the library. If the library threw a runtime error (like Next does in this situation) then your solution would be appropriate. |
The first time I ran into the Date issue, I was surprised Remix didn't warn me that I can't return Dates. Making people explicitly convert to strings before returning from loaders has my vote. |
@edwin177 Could you raise a separate issue about that? |
I’ve been around Remix long enough to know that everyone that’s been asking for inferred types are going to be highly confused and disappointed that the types will be different going in and coming out. I’m surprised you’re considering this a separate issue. I prefer explicit types so not an issue for me. The loader API makes it seem like you put data in on one end and get the same data out at the other. The fact that they are 2 separate functions is kind of immaterial to those asking for implicit types. The use of JSON is explicit since that’s the name of the function, so I don’t think it’s a stretch to ensure only JSON values go in since that’s what comes out. |
@kiliman Since that's a change to existing behaviour, could you put that in a separate issue? I want to keep this issue only about adding TS inference to the current behaviour. |
@kiliman, I disagree with you on this. I prefer my types to describe what is possible. The fact is that it's possible for me to call In practice, people will see that their So again, here are my requirements of whatever we do:
|
I'm still leaning on the side of disallowing types that "transform" on the other end, and here's why. I feel like relying on the default Date serialization is going to be a mistake in more cases than not, and similar for any other non-primitive. Also because there's apparently a whole rabbit hole when it comes to stringify. So for me it's a practical thing. I think it makes more practical sense to make the user specify their intent (how it should turn into a string, not implicitly), and it's easier that way in the typings too. |
I'm not sure I understand what you're suggesting, @itsMapleLeaf. All I'm saying is that the types should allow you to do what can be done. Currently the only serialization/deserialization that happens within Remix is basically
In practice someone will notice that the But my whole objective here is to simply make the over-the-network typing experience more true-to-what's-happening and I welcome a proposal that handles the requirements I've laid out :) (Also, thank you everyone who's contributed to this conversation, I think we'll come up with something really great out of this). |
Again I’m not even arguing for a feature that I’m going to use. I’m just saying that what you guys are proposing is NOT what everyone that has asked for type inference is asking for. I’ve answered this question hundreds of times on Discord, Twitter and here on GitHub. It’s probably the #2 question, where the first is “can parent loaders pass data to child loaders?” As always it’s a documentation/training issue but I can guarantee you that it will trip up pretty much everyone as it doesn’t match their expectation. Anyway it’s not a hill for me to die on. |
And all I'm saying is this is not what we're trying to deal with in this conversation. Right now we're just trying to make the typing better than it is already. This is why I think Matt suggested making a separate GitHub discussion, because that's another topic. All I want to do is take the existing behavior and model the TypeScript types after that and reduce boilerplate while we're at it. |
A few points of discussion relating to the technical approach. All of this is reflected in my PR: #3276 1. Restrict
|
Also: if anyone is looking to stress test the type conversion logic, here's my pass at it. This is a type-level implementation of JavaScript's built-in type JsonPrimitives = string | number | boolean | null;
type NonJsonPrimitives = undefined | Function | symbol;
type SerializeType<T> = T extends JsonPrimitives
? T
: T extends undefined
? undefined
: T extends { toJSON(): infer U }
? U
: T extends []
? []
: T extends [any, ...any[]]
? {
[k in keyof T]: T[k] extends NonJsonPrimitives
? null
: SerializeType<T[k]>;
}
: T extends (infer U)[]
? SerializeType<U>[]
: {
[k in keyof T as T[k] extends NonJsonPrimitives
? never
: k]: SerializeType<T[k]>;
}; |
@colinhacks, thank you so much for taking the time to implement these requirements. I'm going to move conversation over to your PR. |
No description provided.