Skip to content

Commit

Permalink
Enhance client data type inference (#8269)
Browse files Browse the repository at this point in the history
  • Loading branch information
brophdawg11 committed Dec 13, 2023
1 parent a23ecb6 commit dabccfe
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 13 deletions.
6 changes: 5 additions & 1 deletion packages/remix-server-runtime/jsonify.ts
Expand Up @@ -21,6 +21,9 @@ export type Jsonify<T> =
T extends Number ? number :
T extends Boolean ? boolean :

// Promises JSON.stringify to an empty object
T extends Promise<unknown> ? EmptyObject :

// Map & Set
T extends Map<unknown, unknown> ? EmptyObject :
T extends Set<unknown> ? EmptyObject :
Expand Down Expand Up @@ -119,6 +122,7 @@ type _tests = [
Expect<Equal<Jsonify<String>, string>>,
Expect<Equal<Jsonify<Number>, number>>,
Expect<Equal<Jsonify<Boolean>, boolean>>,
Expect<Equal<Jsonify<Promise<string>>, EmptyObject>>,

// Map & Set
Expect<Equal<Jsonify<Map<unknown, unknown>>, EmptyObject>>,
Expand Down Expand Up @@ -251,7 +255,7 @@ type NeverToNull<T> = [T] extends [never] ? null : T;

// adapted from https://github.com/sindresorhus/type-fest/blob/main/source/empty-object.d.ts
declare const emptyObjectSymbol: unique symbol;
type EmptyObject = { [emptyObjectSymbol]?: never };
export type EmptyObject = { [emptyObjectSymbol]?: never };

// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts
type IsAny<T> = 0 extends 1 & T ? true : false;
4 changes: 2 additions & 2 deletions packages/remix-server-runtime/routeModules.ts
Expand Up @@ -54,7 +54,7 @@ type ClientActionFunction = (
* Arguments passed to a route `clientAction` function
* @private Public API is exported from @remix-run/react
*/
type ClientActionFunctionArgs = RRActionFunctionArgs<undefined> & {
export type ClientActionFunctionArgs = RRActionFunctionArgs<undefined> & {
serverAction: <T = AppData>() => Promise<SerializeFrom<T>>;
};

Expand Down Expand Up @@ -87,7 +87,7 @@ type ClientLoaderFunction = ((
* Arguments passed to a route `clientLoader` function
* @private Public API is exported from @remix-run/react
*/
type ClientLoaderFunctionArgs = RRLoaderFunctionArgs<undefined> & {
export type ClientLoaderFunctionArgs = RRLoaderFunctionArgs<undefined> & {
serverLoader: <T = AppData>() => Promise<SerializeFrom<T>>;
};

Expand Down
108 changes: 98 additions & 10 deletions packages/remix-server-runtime/serialize.ts
@@ -1,21 +1,60 @@
import type { Jsonify } from "./jsonify";
import type { EmptyObject, Jsonify } from "./jsonify";
import type { TypedDeferredData, TypedResponse } from "./responses";
import type {
ClientActionFunctionArgs,
ClientLoaderFunctionArgs,
} from "./routeModules";
import { expectType } from "./typecheck";
import { type Expect, type Equal } from "./typecheck";

// prettier-ignore
/**
* Infer JSON serialized data type returned by a loader or action.
* Infer JSON serialized data type returned by a loader or action, while
* avoiding deserialization if the input type if it's a clientLoader or
* clientAction that returns a non-Response
*
* For example:
* `type LoaderData = SerializeFrom<typeof loader>`
*/
export type SerializeFrom<T> =
T extends (...args: any[]) => infer Output ? Serialize<Awaited<Output>> :
T extends (...args: any[]) => infer Output ?
Parameters<T> extends [ClientLoaderFunctionArgs | ClientActionFunctionArgs] ?
// Client data functions may not serialize
SerializeClient<Awaited<Output>>
:
// Serialize responses
Serialize<Awaited<Output>>
:
// Back compat: manually defined data type, not inferred from loader nor action
Jsonify<Awaited<T>>
;

// note: cannot be inlined as logic requires union distribution
// prettier-ignore
type SerializeClient<Output> =
Output extends TypedDeferredData<infer U> ?
// top-level promises
& {
[K in keyof U as K extends symbol
? never
: Promise<any> extends U[K]
? K
: never]: DeferValueClient<U[K]>; // use generic to distribute over union
}
// non-promises
& {
[K in keyof U as Promise<any> extends U[K] ? never : K]: U[K];
}
:
Output extends TypedResponse<infer U> ? Jsonify<U> :
Awaited<Output>

// prettier-ignore
type DeferValueClient<T> =
T extends undefined ? undefined :
T extends Promise<unknown> ? Promise<Awaited<T>> :
T;

// note: cannot be inlined as logic requires union distribution
// prettier-ignore
type Serialize<Output> =
Expand Down Expand Up @@ -49,16 +88,45 @@ type DeferValue<T> =

type Pretty<T> = { [K in keyof T]: T[K] };

type Loader<T> = () => Promise<
| TypedResponse<T> // returned responses
| TypedResponse<never> // thrown responses
>;
type Loader<T> = () => Promise<TypedResponse<T>>;

type LoaderDefer<T extends Record<keyof unknown, unknown>> = () => Promise<
| TypedDeferredData<T> // returned responses
| TypedResponse<never> // thrown responses
TypedDeferredData<T>
>;

type LoaderBoth<
T1 extends Record<keyof unknown, unknown>,
T2 extends Record<keyof unknown, unknown>
> = () => Promise<TypedResponse<T1> | TypedDeferredData<T2>>;

type ClientLoaderRaw<T extends Record<keyof unknown, unknown>> = ({
request,
}: ClientLoaderFunctionArgs) => Promise<T>; // returned non-Response

type ClientLoaderResponse<T extends Record<keyof unknown, unknown>> = ({
request,
}: ClientLoaderFunctionArgs) => Promise<TypedResponse<T>>; // returned responses

type ClientLoaderDefer<T extends Record<keyof unknown, unknown>> = ({
request,
}: ClientLoaderFunctionArgs) => Promise<TypedDeferredData<T>>; // returned responses

type ClientLoaderResponseAndDefer<
T1 extends Record<keyof unknown, unknown>,
T2 extends Record<keyof unknown, unknown>
> = ({
request,
}: ClientLoaderFunctionArgs) => Promise<
TypedResponse<T1> | TypedDeferredData<T2>
>;

type ClientLoaderRawAndDefer<
T1 extends Record<keyof unknown, unknown>,
T2 extends Record<keyof unknown, unknown>
> = ({
request,
}: ClientLoaderFunctionArgs) => Promise<T1 | TypedDeferredData<T2>>;

// prettier-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _tests = [
Expand All @@ -78,7 +146,27 @@ type _tests = [
Expect<Equal<Pretty<SerializeFrom<Loader<{a: string, name: number, data: boolean}>>>, {a: string, name: number, data: boolean}>>,

// defer top-level promises
Expect<SerializeFrom<LoaderDefer<{ a: string; lazy: Promise<{ b: number }>}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>
Expect<SerializeFrom<LoaderDefer<{ a: string; lazy: Promise<{ b: number }>}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>,

// conditional defer or json
Expect<SerializeFrom<LoaderBoth<{ a:string, b: Promise<string> }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>,

// clientLoader raw JSON
Expect<Equal<Pretty<SerializeFrom<ClientLoaderRaw<{a: string}>>>, {a: string}>>,
Expect<Equal<Pretty<SerializeFrom<ClientLoaderRaw<{a: Date, b: Map<string,number> }>>>, {a: Date, b: Map<string,number>}>>,

// clientLoader json() Response
Expect<Equal<Pretty<SerializeFrom<ClientLoaderResponse<{a: string}>>>, {a: string}>>,
Expect<Equal<Pretty<SerializeFrom<ClientLoaderResponse<{a: Date}>>>, {a: string}>>,

// clientLoader defer() data
Expect<SerializeFrom<ClientLoaderDefer<{ a: string; lazy: Promise<{ b: number }>}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>,

// clientLoader conditional defer or json
Expect<SerializeFrom<ClientLoaderResponseAndDefer<{ a: string, b: Promise<string> }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>,

// clientLoader conditional defer or raw
Expect<SerializeFrom<ClientLoaderRawAndDefer<{ a: string, b: Promise<string> }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: Promise<string> } | { c: string; lazy: Promise<{ d: number }> } ? true : false>,
];

// recursive
Expand Down

0 comments on commit dabccfe

Please sign in to comment.