Skip to content

Commit

Permalink
Implement serialization type logic
Browse files Browse the repository at this point in the history
  • Loading branch information
colinhacks committed May 31, 2022
1 parent 58bb8a7 commit 0bc398c
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 32 deletions.
58 changes: 58 additions & 0 deletions packages/remix-react/__tests__/hook-types-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ describe("useLoaderData", () => {
isEqual<response, { hello: string }>(true);
});

it("supports plain Response", () => {
type Loader = (args: any) => Response;
type response = UseDataFunction<Loader>;
isEqual<response, any>(true);
});

it("supports Response-returning loader", () => {
type Loader = (args: any) => TypedResponse<{ hello: string }>;
type response = UseDataFunction<Loader>;
Expand All @@ -35,3 +41,55 @@ describe("useLoaderData", () => {
isEqual<response, { hello: string }>(true);
});
});

describe("type serializer", () => {
it("converts Date to string", () => {
type AppData = { hello: Date };
type response = UseDataFunction<AppData>;
isEqual<response, { hello: string }>(true);
});

it("supports custom toJSON", () => {
type AppData = { toJSON(): { data: string[] } };
type response = UseDataFunction<AppData>;
isEqual<response, { data: string[] }>(true);
});

it("supports recursion", () => {
type AppData = { dob: Date; parent: AppData };
type SerializedAppData = { dob: string; parent: SerializedAppData };
type response = UseDataFunction<AppData>;
isEqual<response, SerializedAppData>(true);
});

it("supports tuples and arrays", () => {
type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] };
type response = UseDataFunction<AppData>;
isEqual<
response,
{ arr: string[]; tuple: [string, number, string]; empty: [] }
>(true);
});

it("transforms unserializables to null in arrays", () => {
type AppData = [Function, symbol, undefined];
type response = UseDataFunction<AppData>;
isEqual<response, [null, null, null]>(true);
});

it("transforms unserializables to never in objects", () => {
type AppData = { arg1: Function; arg2: symbol; arg3: undefined };
type response = UseDataFunction<AppData>;
isEqual<response, {}>(true);
});

it("supports class instances", () => {
class Test {
arg: string;
speak: () => string;
}
type Loader = (args: any) => TypedResponse<Test>;
type response = UseDataFunction<Loader>;
isEqual<response, { arg: string }>(true);
});
});
36 changes: 31 additions & 5 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1303,11 +1303,37 @@ export type TypedResponse<T> = Response & {
};
type DataFunction = (...args: any[]) => any;
type DataOrFunction = AppData | DataFunction;
export type UseDataFunction<T extends DataOrFunction> = T extends DataFunction
? Awaited<ReturnType<T>> extends TypedResponse<infer U>
? U
: Awaited<ReturnType<T>>
: Awaited<T>;
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]>;
};

export type UseDataFunction<T extends DataOrFunction> = T extends (
...args: any[]
) => infer Output
? Awaited<Output> extends TypedResponse<infer U>
? SerializeType<U>
: SerializeType<Awaited<ReturnType<T>>>
: SerializeType<Awaited<T>>;
export function useLoaderData<T = AppData>(): UseDataFunction<T> {
return useRemixRouteContext().data;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/remix-react/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export interface ThrownResponse<
data: Data;
}

export interface SerializedError {
// must be type alias due to inference issues on interfaces
// https://github.com/microsoft/TypeScript/issues/15300
export type SerializedError = {
message: string;
stack?: string;
}
};
10 changes: 9 additions & 1 deletion packages/remix-server-runtime/__tests__/responses-test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TypedResponse } from "../index";
import { json, redirect } from "../index";
import { isEqual } from "./utils";

Expand Down Expand Up @@ -38,10 +39,17 @@ describe("json", () => {

it("infers input type", async () => {
let response = json({ hello: "remix" });
isEqual<typeof response, Response<{ hello: string }>>(true);
isEqual<typeof response, TypedResponse<{ hello: string }>>(true);
let result = await response.json();
expect(result).toMatchObject({ hello: "remix" });
});

it("disallows unserializables", () => {
// @ts-expect-error
expect(() => json(124n)).toThrow();
// @ts-expect-error
expect(() => json({ field: 124n })).toThrow();
});
});

describe("redirect", () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/remix-server-runtime/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ export interface ThrownResponse<T = any> {
data: T;
}

export interface SerializedError {
// must be type alias due to inference issues on interfaces
// https://github.com/microsoft/TypeScript/issues/15300
export type SerializedError = {
message: string;
stack?: string;
}
};

export async function serializeError(error: Error): Promise<SerializedError> {
export async function serializeError(error: Error) {
return {
message: error.message,
stack: error.stack,
};
} as SerializedError;
}
1 change: 1 addition & 0 deletions packages/remix-server-runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type {
IsSessionFunction,
JsonFunction,
RedirectFunction,
TypedResponse,
} from "./interface";

// Remix server runtime packages should re-export these types
Expand Down
6 changes: 5 additions & 1 deletion packages/remix-server-runtime/interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export type { CreateCookieFunction, IsCookieFunction } from "./cookies";
export type { JsonFunction, RedirectFunction } from "./responses";
export type {
JsonFunction,
RedirectFunction,
TypedResponse,
} from "./responses";
export type { CreateRequestHandlerFunction } from "./server";
export type {
CreateSessionFunction,
Expand Down
34 changes: 15 additions & 19 deletions packages/remix-server-runtime/responses.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
type JsonInputScalar = string | number | boolean | Date | null | undefined;
type Json = JsonInputScalar | { [key: string]: Json } | Json[];

export type JsonFunction = <Data extends Json>(
type SerializablePrimitives =
| string
| number
| boolean
| null
| { toJSON(): any }
| undefined
| Function
| symbol;
type Serializable =
| SerializablePrimitives
| { [key: string | number | symbol]: Serializable }
| Serializable[];
export type JsonFunction = <Data extends Serializable>(
data: Data,
init?: number | ResponseInit
) => TypedResponse<Data>;

// must be a type since this is a subtype of response
// interfaces must conform to the types they extend
type TypedResponse<T = any> = Response & {
export type TypedResponse<T = any> = Response & {
json(): Promise<T>;
};

// export interface Resp {
// json(): any;
// }
declare global {}
export interface Response<T = any> {
json(): T;
}

const jsonFunction = <T>(arg: T): Response<T> => {
return arg as any;
};
const arg = jsonFunction("asdf");
const out = arg.json(); // any

/**
* This is a shortcut for creating `application/json` responses. Converts `data`
* to JSON and sets the `Content-Type` header.
Expand Down

0 comments on commit 0bc398c

Please sign in to comment.