Skip to content

Commit

Permalink
Single-fetch typesafety: defineLoader and defineAction (#9372)
Browse files Browse the repository at this point in the history
  • Loading branch information
pcattori committed May 9, 2024
1 parent feb25eb commit dafe70e
Show file tree
Hide file tree
Showing 8 changed files with 231 additions and 85 deletions.
50 changes: 50 additions & 0 deletions .changeset/rotten-geckos-yawn.md
@@ -0,0 +1,50 @@
---
"@remix-run/cloudflare": patch
"@remix-run/deno": patch
"@remix-run/node": patch
"@remix-run/react": patch
"@remix-run/server-runtime": patch
---

Typesafety for single-fetch: defineLoader, defineClientLoader, defineAction, defineClientAction

`defineLoader` and `defineAction` are helpers for authoring `loader`s and `action`s.
They are identity functions; they don't modify your loader or action at runtime.
Rather, they exist solely for typesafety by providing types for args and by ensuring valid return types.

```ts
export let loader = defineLoader(({ request }) => {
// ^? Request
return {a:1, b: () => 2}
// ^ type error: `b` is not serializable
})
```

Note that `defineLoader` and `defineAction` are not technically necessary for defining loaders and actions if you aren't concerned with typesafety:

```ts
// this totally works! and typechecking is happy too!
export let loader = () => {
return {a: 1}
}
```

This means that you can opt-in to `defineLoader` incrementally, one loader at a time.

You can return custom responses via the `json`/`defer` utilities, but doing so will revert back to the old JSON-based typesafety mechanism:

```ts
let loader1 = () => {
return {a: 1, b: new Date()}
}
let data1 = useLoaderData<typeof loader1>()
// ^? {a: number, b: Date}

let loader2 = () => {
return json({a: 1, b: new Date()}) // this opts-out of turbo-stream
}
let data2 = useLoaderData<typeof loader2>()
// ^? JsonifyObject<{a: number, b: Date}> which is really {a: number, b: string}
```

You can also continue to return totally custom responses with `Response` though this continues to be outside of the typesystem since the built-in `Response` type is not generic
61 changes: 57 additions & 4 deletions docs/guides/single-fetch.md
Expand Up @@ -98,13 +98,66 @@ In order to ensure you get the proper types when using Single Fetch, we've inclu

```json
{
"include": [
// ...
"node_modules/@remix-run/react/future/single-fetch.d.ts"
]
"compilerOptions": {
"types": ["@remix-run/react/future/single-fetch.d.ts"]
}
}
```

🚨 Make sure the single-fetch types come after any other Remix packages in `types` so that they override those existing types.

** `defineLoader`, `defineAction`, `defineClientLoader`, `defineClientAction` **

To get typesafety when defining loaders and actions, you can use the `defineLoader` and `defineAction` utilities:

```ts
import { defineLoader } from "@remix-run/node";

export const loader = defineLoader(({ request }) => {
// ^? Request
});

export const action = defineAction(({ context }) => {
// ^? AppLoadContext
});
```

Not only does this give you types for any arguments, but it also ensures you are returning single-fetch compatible types:

```ts
export const loader = defineLoader(() => {
return { hello: "world", badData: () => 1 };
// ^^^^^^^ Type error: `badData` is not serializable
});

export const action = defineAction(() => {
return { hello: "world", badData: new CustomType() };
// ^^^^^^^ Type error: `badData` is not serializable
});
```

Single-fetch supports the following return types:

```ts
type Serializable =
| undefined
| null
| boolean
| string
| symbol
| number
| bigint
| Date
| URL
| RegExp
| Error
| Array<Serializable>
| { [key: PropertyKey]: Serializable } // objects with serializable values
| Map<Serializable, Serializable>
| Set<Serializable>
| Promise<Serializable>;
```

**`useLoaderData`, `useActionData`, `useRouteLoaderData`, and `useFetcher`**

These methods do not require any code changes on your part - adding the single fetch types will cause their generics to deserialize correctly:
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-cloudflare/index.ts
Expand Up @@ -12,6 +12,10 @@ export {
export {
createRequestHandler,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-deno/index.ts
Expand Up @@ -16,6 +16,10 @@ export {
export {
broadcastDevReady,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
isCookie,
isSession,
Expand Down
4 changes: 4 additions & 0 deletions packages/remix-node/index.ts
Expand Up @@ -24,6 +24,10 @@ export {
export {
createRequestHandler,
createSession,
unstable_defineLoader,
unstable_defineClientLoader,
unstable_defineAction,
unstable_defineClientAction,
defer,
broadcastDevReady,
logDevReady,
Expand Down
102 changes: 22 additions & 80 deletions packages/remix-react/future/single-fetch.d.ts
@@ -1,100 +1,46 @@
import type { MetaArgs, UIMatch, UNSAFE_MetaMatch } from "@remix-run/react";
import type {
LoaderFunctionArgs,
ActionFunctionArgs,
SerializeFrom,
TypedDeferredData,
TypedResponse,
unstable_Loader as Loader,
unstable_Action as Action,
unstable_Serialize as Serialize,
} from "@remix-run/server-runtime";
import type {
useFetcher as useFetcherRR,
FetcherWithComponents,
} from "react-router-dom";

type Serializable =
| undefined
| null
| boolean
| string
| symbol
| number
| Array<Serializable>
| { [key: PropertyKey]: Serializable }
| bigint
| Date
| URL
| RegExp
| Error
| Map<Serializable, Serializable>
| Set<Serializable>
| Promise<Serializable>;

type DataFunctionReturnValue =
| Serializable
| TypedDeferredData<Record<string, unknown>>
| TypedResponse<Record<string, unknown>>;

type LoaderFunction_SingleFetch = (
args: LoaderFunctionArgs
) => Promise<DataFunctionReturnValue> | DataFunctionReturnValue;
type ActionFunction_SingleFetch = (
args: ActionFunctionArgs
) => Promise<DataFunctionReturnValue> | DataFunctionReturnValue;

// Backwards-compatible type for Remix v2 where json/defer still use the old types,
// and only non-json/defer returns use the new types. This allows for incremental
// migration of loaders to return naked objects. In the next major version,
// json/defer will be removed so everything will use the new simplified typings.
// prettier-ignore
type SingleFetchSerialize_V2<T extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch> =
Awaited<ReturnType<T>> extends TypedDeferredData<infer D> ? D :
Awaited<ReturnType<T>> extends TypedResponse<Record<string, unknown>> ? SerializeFrom<T> :
Awaited<ReturnType<T>>;

declare module "@remix-run/react" {
export function useLoaderData<T>(): T extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<T>
: never;
export function useLoaderData<T extends Loader>(): Serialize<T>;

export function useActionData<T>(): T extends ActionFunction_SingleFetch
? SingleFetchSerialize_V2<T> | undefined
: never;
export function useActionData<T extends Action>(): Serialize<T> | undefined;

export function useRouteLoaderData<T>(
export function useRouteLoaderData<T extends Loader>(
routeId: string
): T extends LoaderFunction_SingleFetch ? SingleFetchSerialize_V2<T> : never;
): Serialize<T>;

export function useFetcher<TData = unknown>(
export function useFetcher<T extends Loader | Action>(
opts?: Parameters<typeof useFetcherRR>[0]
): FetcherWithComponents<
TData extends LoaderFunction_SingleFetch | ActionFunction_SingleFetch
? SingleFetchSerialize_V2<TData>
: never
>;
): FetcherWithComponents<Serialize<T>>;

export type UIMatch_SingleFetch<D = unknown, H = unknown> = Omit<
UIMatch<D, H>,
"data"
> & {
data: D extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<D>
: never;
data: D extends Loader ? Serialize<D> : never;
};

interface MetaMatch_SingleFetch<
RouteId extends string = string,
Loader extends LoaderFunction_SingleFetch | unknown = unknown
> extends Omit<UNSAFE_MetaMatch<RouteId, Loader>, "data"> {
data: Loader extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<Loader>
: unknown;
L extends Loader | unknown = unknown
> extends Omit<UNSAFE_MetaMatch<RouteId, L>, "data"> {
data: L extends Loader ? Serialize<L> : unknown;
}

type MetaMatches_SingleFetch<
MatchLoaders extends Record<
MatchLoaders extends Record<string, Loader | unknown> = Record<
string,
LoaderFunction_SingleFetch | unknown
> = Record<string, unknown>
unknown
>
> = Array<
{
[K in keyof MatchLoaders]: MetaMatch_SingleFetch<
Expand All @@ -105,17 +51,13 @@ declare module "@remix-run/react" {
>;

export interface MetaArgs_SingleFetch<
Loader extends LoaderFunction_SingleFetch | unknown = unknown,
MatchLoaders extends Record<
L extends Loader | unknown = unknown,
MatchLoaders extends Record<string, Loader | unknown> = Record<
string,
LoaderFunction_SingleFetch | unknown
> = Record<string, unknown>
> extends Omit<MetaArgs<Loader, MatchLoaders>, "data" | "matches"> {
data:
| (Loader extends LoaderFunction_SingleFetch
? SingleFetchSerialize_V2<Loader>
: unknown)
| undefined;
unknown
>
> extends Omit<MetaArgs<L, MatchLoaders>, "data" | "matches"> {
data: (L extends Loader ? Serialize<L> : unknown) | undefined;
matches: MetaMatches_SingleFetch<MatchLoaders>;
}
}
13 changes: 12 additions & 1 deletion packages/remix-server-runtime/index.ts
Expand Up @@ -5,11 +5,22 @@ export {
parseMultipartFormData as unstable_parseMultipartFormData,
} from "./formData";
export { defer, json, redirect, redirectDocument } from "./responses";

export {
SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol,
defineLoader as unstable_defineLoader,
defineClientLoader as unstable_defineClientLoader,
defineAction as unstable_defineAction,
defineClientAction as unstable_defineClientAction,
} from "./single-fetch";
export type {
Loader as unstable_Loader,
Action as unstable_Action,
Serialize as unstable_Serialize,
SingleFetchResult as UNSAFE_SingleFetchResult,
SingleFetchResults as UNSAFE_SingleFetchResults,
} from "./single-fetch";
export { SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol } from "./single-fetch";

export { createRequestHandler } from "./server";
export {
createSession,
Expand Down

0 comments on commit dafe70e

Please sign in to comment.