Skip to content

Commit

Permalink
fix: correctly infer deferred types for top-level promises
Browse files Browse the repository at this point in the history
  • Loading branch information
pcattori committed Aug 8, 2023
1 parent 8bc1b13 commit d4d6128
Show file tree
Hide file tree
Showing 4 changed files with 68 additions and 69 deletions.
5 changes: 5 additions & 0 deletions .changeset/tasty-apricots-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/server-runtime": patch
---

correctly infer deferred types for top-level promises
88 changes: 45 additions & 43 deletions packages/remix-server-runtime/__tests__/serialize-test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,54 @@
import type { SerializeFrom } from "../index";
import { defer, json } from "../index";
import type { IsNever } from "./utils";
import { isEqual } from "./utils";

describe("SerializeFrom", () => {
it("infers types", () => {
isEqual<SerializeFrom<string>, string>(true);
isEqual<SerializeFrom<number>, number>(true);
isEqual<SerializeFrom<boolean>, boolean>(true);
isEqual<SerializeFrom<String>, String>(true);
isEqual<SerializeFrom<Number>, Number>(true);
isEqual<SerializeFrom<Boolean>, Boolean>(true);
isEqual<SerializeFrom<null>, null>(true);

isEqual<IsNever<SerializeFrom<undefined>>, true>(true);
isEqual<IsNever<SerializeFrom<Function>>, true>(true);
isEqual<IsNever<SerializeFrom<symbol>>, true>(true);

isEqual<SerializeFrom<[]>, []>(true);
isEqual<SerializeFrom<[string, number]>, [string, number]>(true);
isEqual<SerializeFrom<[number, number]>, [number, number]>(true);

isEqual<SerializeFrom<ReadonlyArray<string>>, string[]>(true);
isEqual<SerializeFrom<ReadonlyArray<Function>>, null[]>(true);

isEqual<SerializeFrom<{ hello: "remix" }>, { hello: "remix" }>(true);
isEqual<
SerializeFrom<{ data: { hello: "remix" } }>,
{ data: { hello: "remix" } }
>(true);
});
it("infers basic types", () => {
isEqual<
SerializeFrom<{
hello?: string;
count: number | undefined;
date: Date | number;
isActive: boolean;
items: { name: string; price: number; orderedAt: Date }[];
}>,
{
hello?: string;
count?: number;
date: string | number;
isActive: boolean;
items: { name: string; price: number; orderedAt: string }[];
}
>(true);
});

it("infers type from json responses", () => {
let loader = () => json({ hello: "remix" });
isEqual<SerializeFrom<typeof loader>, { hello: string }>(true);
it("infers deferred types", () => {
let get = (): Promise<string> | undefined => {
if (Math.random() > 0.5) return Promise.resolve("hello");
return undefined;
};
let loader = async () =>
defer({
critical: await Promise.resolve("hello"),
deferred: get(),
});
isEqual<
SerializeFrom<typeof loader>,
{
critical: string;
deferred: Promise<string> | undefined;
}
>(true);
});

let asyncLoader = async () => json({ hello: "remix" });
isEqual<SerializeFrom<typeof asyncLoader>, { hello: string }>(true);
});
it("infers types from json", () => {
let loader = () => json({ data: "remix" });
isEqual<SerializeFrom<typeof loader>, { data: string }>(true);

it("infers type from defer responses", () => {
let loader = async () => defer({ data: { hello: "remix" } });
isEqual<SerializeFrom<typeof loader>, { data: { hello: string } }>(true);
});
let asyncLoader = async () => json({ data: "remix" });
isEqual<SerializeFrom<typeof asyncLoader>, { data: string }>(true);
});

// Special case that covers https://github.com/remix-run/remix/issues/5211
it("infers type from json responses containing a data key", () => {
let loader = async () => json({ data: { hello: "remix" } });
isEqual<SerializeFrom<typeof loader>, { data: { hello: string } }>(true);
});
it("infers type from defer", () => {
let loader = async () => defer({ data: "remix" });
isEqual<SerializeFrom<typeof loader>, { data: string }>(true);
});
2 changes: 0 additions & 2 deletions packages/remix-server-runtime/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,3 @@ export function prettyHtml(source: string): string {
export function isEqual<A, B>(
arg: A extends B ? (B extends A ? true : false) : false
): void {}

export type IsNever<T> = [T] extends [never] ? true : false;
42 changes: 18 additions & 24 deletions packages/remix-server-runtime/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,29 @@ import type { AppData } from "./data";
import type { TypedDeferredData, TypedResponse } from "./responses";

// Note: The return value has to be `any` and not `unknown` so it can match `void`.
type ArbitraryFunction = (...args: any[]) => any;
type NotJsonable = ArbitraryFunction | undefined | symbol;

type Serialize<T> = T extends TypedDeferredData<infer U>
? SerializeDeferred<U>
: Jsonify<T>;
type Fn = (...args: any[]) => any;

// prettier-ignore
type SerializeDeferred<T extends Record<string, unknown>> = {
[k in keyof T as
T[k] extends Promise<unknown> ? k :
T[k] extends NotJsonable ? never :
k
]:
T[k] extends Promise<infer U>
? Promise<Serialize<U>> extends never ? "wtf" : Promise<Serialize<U>>
: Serialize<T[k]> extends never ? k : Serialize<T[k]>;
};

/**
* Infer JSON serialized data type returned by a loader or action.
*
* For example:
* `type LoaderData = SerializeFrom<typeof loader>`
*/
export type SerializeFrom<T extends AppData | ArbitraryFunction> = Serialize<
T extends (...args: any[]) => infer Output
? Awaited<Output> extends TypedResponse<infer U>
? U
: Awaited<Output>
: Awaited<T>
>;
export type SerializeFrom<T extends AppData | Fn> =
T extends (...args: any[]) => infer Output ?
Awaited<Output> extends TypedResponse<infer U> ? Jsonify<U> :
Awaited<Output> extends TypedDeferredData<infer U> ?
& {
[K in keyof U as
K extends symbol ? never :
Promise<any> extends U[K] ? K :
never
]:
U[K] extends Promise<any> ? Promise<Jsonify<Awaited<U[K]>>> : U[K]
}
& Jsonify<{ [K in keyof U as Promise<any> extends U[K] ? never : K]: U[K] }>
:
Jsonify<Awaited<Output>> :
Jsonify<Awaited<T>>
;

0 comments on commit d4d6128

Please sign in to comment.