-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add loader
type inference
#3276
Conversation
Hi @colinhacks, Welcome, and thank you for contributing to Remix! Before we consider your pull request, we ask that you sign our Contributor License Agreement (CLA). We require this only once. You may review the CLA and sign it by adding your name to contributors.yml. Once the CLA is signed, the If you have already signed the CLA and received this response in error, or if you have any questions, please contact us at hello@remix.run. Thanks! - The Remix team |
Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳 |
How about restricting type JSONValue =
| string
| number
| boolean
| { [x: string]: JSONValue }
| Array<JSONValue>; |
I suggested this in #1254 but I'd want confirmation from the team before I implement it. It's possible it'll break some codebases if the return type of loader is type JSON =
| string
| number
| boolean
| {[x: string]: JSON}
| Array<JSON>;
const arg: JSON = {asdf: 'asdf' as unknown};
// ^ not assignable to type JSON Not sure how common this is but I don't think it's a big problem. Worth the improved type safety IMO. |
Can we extend this to work with I'd suggest |
d459800
to
0bc398c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you so much for taking the time to implement this @colinhacks. This looks really good to me. I appreciate the added tests as well.
Could you help me understand why we need a TypedResponse
type? As far as the user (developer) is concerned, they don't ever call response.json()
themselves, so the fact that it returns a promise of the correct type shouldn't make a difference right?
EDIT: Actually, I think I understand why it's necessary. It's for the useLoaderData
generic to be able to get the type out of typeof loader
.
Next question, what happens when the loader returns a redirect
or a new Response()
?
Oops got busy with travel and weddings! Back in the swing of things now.
Exactly 🤙
Good question. If you don't use One interesting case: if the user export const loader = async (args: LoaderArgs) => {
const sessionCookie = args.request.headers.get("cookie");
if (!sessionCookie) {
throw redirect("/login");
}
return json({ ok: true });
};
export default function Home(){
const data = useLoaderData<typeof loader>(); // { ok: boolean }
} Unfortunately conditionally returning a redirect makes type inference impossible: export const loader = (args: LoaderArgs) => {
if (Math.random()) return redirect("/");
return json({ ok: true });
}
export default function Home(){
const data = useLoaderData<typeof loader>(); // any
} You may have assumed that the return type of this loader is For this reason, the docs should always recommend |
Regarding |
So, to be clear, we'd be fine if we do: export const loader = (args: LoaderArgs) => {
if (Math.random()) throw redirect("/");
return json({ ok: true });
} I think I can live with that. I'm on a family trip right now (just hitting GitHub on some down-time), but when I get the chance I'll talk with Ryan and Michael about this. Thanks for your patience! |
Ooh good idea @itsMapleLeaf! I updated the PR to use that solution. This way, users don't need to remember to
🤙 no rush |
Thanks, @colinhacks! I took a rudimentary attempt at this earlier this year and will be glad to see this get merged. Let me know if you need any help with testing edge cases. |
Oh wow, hadn't seen that discussion! I see you also chose a simplified |
I'm convinced that // 1. explicit type cast
useLoaderData() as InferLoaderData<typeof loader>
// 2. generic, uses `InferLoaderData`
useLoaderData<InferLoaderData<typeof loader>>()
// 3. for convenience, generic uses `typeof loader` and `useLoaderData` will use `InferLoaderData` implicitly
useLoaderData<typeof loader>() I like the terseness of (3), but I think I lean towards (1) because AFAIK @colinhacks I know you've expressed some opinions on this already, but the nuance I'm interested in isn't that a lie is happening, its whether that lie is hidden or clearly visible to the user. @MichaelDeBoey @mattpocock I'm also interested in hearing which option 1, 2, or 3 you think is best, or more specifically, if you think a generic that smuggles in a type cast is nasty or fine or something else. |
I like In essence, as a user of the I would suspect the return type to be exactly as what's happening behind the scenes (so only valid serialized return values from Nr. 1 is my least favorite tbh. |
Having talked about this with Pedro, I'm convinced option 1 is the option I "hate the least" so I vote that one. |
Seems you're trying to pick an API that actively encourages users to distrust the inferred types. If that's your goal then we are definitely not trying to accomplish the same thing. 🙃 A few points relating to this discussion. a) DX and adoptionFirst things first: there's no difference in "typesafety" among those three options. They all achieve the same thing in different ways. TypeScript users who really care about full typesafety will use whatever syntax you provide, the question is just a matter of how verbose/onerous the experience will be. My primary concern isn't whether or not the "lie" is obvious to the user; I'm far more worried about users not bothering with typing at all because the idiomatic approach to getting types is too annoying. b) Clients should trust the serverThe scenario I've been imagining is a user who's using a typesafe ORM to fetch data in their loader. The return type of these ORM calls is generally trustworthy and probably enforced at runtime internally by the ORM. The loader code runs on the server in a trusted environment. Generally the client-side code should consider server code and it's inferred types trustworthy. That said, it's very possible users will use type annotations that make the static type inconsistent with runtime: export const loader = (args: LoaderArgs)=>{
const data: unknown = { message: 5 };
return data as { message: string };
}; But in this scenario, they're already using an explicit type cast — c) TS utility types 😑Admittedly |
Summarizing a conversation I had with @ryanflorence about this: The root cause here is that One thought is that for option (3), type purists could still omit the generic to get let data = useLoaderData() // unknown
let product = productSchema.parse(data) // use `zod` or `invariant` or another runtime checking library to get type safety Unfortunately if we use (2) or (3), for purists that are just following docs/examples that means TS will never warn them that data from let product = useLoaderData<typeof loader>() // TS won't warn purists that we're never validating this data to make sure it aligns with the return type of `loader` But for semi-purists or users who don't care as much about strict, 100% type safety, it'd be nice if they weren't required to import Plus This all makes me lean toward (3) as long as:
I think the best place for documenting that the generic can be omitted is in the jsdoc for P.S. Sorry @kentcdodds! Just after I got more convinced of (1) when talking to you, I waffled one more time to (3) 😅 |
It's ("asdf" as any).whatever[6].stuff; // works
('asdf' as unknown).whatever; // SyntaxError
True purists are the ones who are most likely to hate a syntax that involves a type cast. The need for a type cast indicates that you are either a) failing to properly infer a type upstream or b) runtime validation with Zod, etc is needed. The purist approach would involve using a typesafe ORM in their loader, which provides trustable types. It would also then be redundant to revalidate But in any case this is definitely a documentable pattern. (Aside: You wouldn't want to validate inline since validation can be CPU-intensive; you'd use |
Has that patch been published yet? |
It's probably in |
I think I have found a bug 🐛 Using the Final API provided in the descriptions of this PR, just include the The import {LoaderArgs} from "@remix-run/node";
const loader = async (args: LoaderArgs)=>{
return { count: 1 };
return json({ hello: "data" }); // TypedResponse<{hello: string}>
}
export default App(){
// ⚠️ data is not inferred correctly here
const data = useLoaderData<typeof loader>()
return <div>{data}</div>
} |
Another reason why I prefer to type my loader data explicitly. I just think we're expecting too much from TypeScript here. |
@lemol you mean you have two return statements in your loader? Why would you do that? |
@kiliman good news is that even with this feature you can continue to manually type everything you like. Personally I think we're not expecting too much and this is going to be a huge productivity boost. |
Looks like this is breaking
How can we make sure we don't break that? |
That was just to illustrate when there is a mix of returned types. Actual useful example may be returning conditionally: const loader = async (args: LoaderArgs)=>{
if (some condition) {
return { count: 1 };
}
return json({ hello: "data" }); // TypedResponse<{hello: string}>
} |
@lemol I would consider the bug in this example to be not returning the const loader = async (args: LoaderArgs)=>{
if (some condition) {
- return { count: 1 };
+ return json({ count: 1 });
}
return json({ hello: "data" }); // TypedResponse<{count: number} | {hello: string}>
} |
P.S. @colinhacks this works really well for me on the dev branch, can't wait to see this released! 🥳 |
Good catch @joshmedeski, just came to say the exact thing! @kentcdodds This is interesting. The loader data that the hook returns wouldn't have a
which is correct, depending on But real life is messy and it's not great for people to update Remix and have type errors (even for wrong things). I guess it would be a breaking change that could wrap |
@joshmedeski is correct. This is a new feature which you opt-into so it's non-breaking. We can say: "if you want to use this feature ( |
Correct. It's embarrassing that I didn't notice that in the actual error and also that we've gone so long having that time bomb in our starters (FWIW, most people probably delete the I think what we should do in this situation is push forward and we'll call out this in the notes. In a world where bug fixes can break people's CI, every new version could be considered a major version bump. I don't want to live in a world like that, especially when it's just CI and not production code since it's just TypeScript compilation that will be affected. |
This has been released in v1.6.5 🎉 However it's not working as expected in remix-run/indie-stack#122 🤔 |
Ah, I didn't realize that we won't be using Thanks 🎉 |
@kentcdodds I had to do the same thing in my original solution. I don't see much benefit from |
Yep. And now I get to use function declarations 🎉 |
Should this approach provide an easy type for Having a hard time narrowing this type type LoaderData = Awaited<ReturnType<typeof loader>> // Response & { json(): Promise<{ name: string }> } |
@theMosaad I think you're looking for this: type LoaderData = UseDataFunctionReturn<typeof loader> :) But yeah, I think we could probably do something to help with this in the |
Hi! Is there any official documentation for this or only in this PR? Thanks! |
@kubahasek not yet, but it's being worked on. All the docs/examples/stacks should get an update soon. |
@kentcdodds This is working great for me! Is it normal/expected that I should |
Oh, we should definitely export that. PR welcome! 😅 |
@kentcdodds PR to export |
less code and awesome type security 😊 https://github.com/remix-run/remix/releases/tag/remix%401.6.5 remix-run/remix#3276
As discussed in #1254
Changes
All changes are fully backwards compatible.
json
to return aTypedResponse
DataFunctionArgs
toLoaderArgs
andActionArgs
(more memorable/intuitive)useLoaderProps
to accepttypeof loader
as generic parameter (instead ofAwaited<ReturnType<typeof loader>>
)Here
TypedResponse
conforms to theResponse
interface as is defined like so.Final API
Testing Strategy:
remix-server-runtime > responses-test.ts
: added tests ensuring that the return type ofjson
is a genericTypedResponse
and non-serializable data isn't supported injson
remix-react > hook-types-test
checks the type logic foruseLoaderData
Guidance needed:
Documentation
In my opinion, all TypeScript examples should be changed to use
LoaderArgs
, which allows TypeScript to properly infer the return type:There are lot of instances of the old approach in the docs. I'm happy to change them but wanted to get confirmation first.