From 5c9539d0b56bb40c75f8f715c8c6d63ac68e2bf5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 18 Aug 2022 18:07:16 -0400 Subject: [PATCH] feat(@remix-run/cloudflare,@remix-run/deno,@remix-run/node): `SerializeFrom` utility for loader and action type inference (#4013) * docs(@remix-run/react): reunite `useLoaderData` with its jsdoc * refactor(@remix-run/react): factor out `SerializeType` * refactor(@remix-run/react): singularize union types convention is to use plurals for collections (e.g. arrays), not for unions * refactor(@remix-run/react): properly constrain objects in typescript arrays are technically `object`s, so we need something more specific. `Record` should match any object, but not match arrays. * refactor(@remix-run/react): remove redundant "Type" suffix from typescript utilities * refactor(@remix-run/react): rename `UndefinedOptionals` to `UndefinedToOptional` more obvious that this _converts_ `undefined` unions to optionals * refactor(@remix-run/react): factor out tuple serialization into type utility and to be consistent with `SerializeObject` * fix(@remix-run/react): `Serialize` should return `any` * fix(@remix-run/react): serialize classes and provide comments to explain what tuples are and how `object` matches classes * style(@remix-run/react): do not auto-format typescript ternaries makes each line in the type definition independent of other lines. more readable and easier to comment/uncomment specific conditions. plus less git noise when adding/removing/reordering conditions. * refactor(@remix-run/react): move serialize type utilities into their own module * refactor(@remix-run/react): inline `DataOrFunction` type since its only used once * refactor(@remix-run/react): rename `UseDataFunctionReturn` to `SerializeFrom` since the type can be used without `useLoaderData` or `useActionData` and just returns the JSON serialized data from a loader or action * chore(lint): ignore eslint warning about unused typescript generic that _is_ being used * docs(@remix-run/react): jsdoc for `SerializeFrom` * chore(@remix-run/react): add comment explaining `IsAny` implementation * refactor(serialize): move serialize type utilities into `@remix-run/server-runtime` * feat(@remix-run/deno,@remix-run/cloudflare,@remix-run/node): export `SerializeFrom` type utility also, sync re-exports from `@remix-run/server-runtime` * Create neat-beds-unite.md --- .changeset/neat-beds-unite.md | 20 +++++ packages/remix-cloudflare/index.ts | 7 +- packages/remix-deno/index.ts | 7 +- packages/remix-node/index.ts | 8 +- .../remix-react/__tests__/hook-types-test.tsx | 37 +++++---- packages/remix-react/components.tsx | 78 +------------------ packages/remix-react/package.json | 1 + packages/remix-server-runtime/index.ts | 7 +- packages/remix-server-runtime/interface.ts | 6 +- packages/remix-server-runtime/reexport.ts | 4 + packages/remix-server-runtime/serialize.ts | 78 +++++++++++++++++++ 11 files changed, 147 insertions(+), 106 deletions(-) create mode 100644 .changeset/neat-beds-unite.md create mode 100644 packages/remix-server-runtime/serialize.ts diff --git a/.changeset/neat-beds-unite.md b/.changeset/neat-beds-unite.md new file mode 100644 index 00000000000..87be7e908ca --- /dev/null +++ b/.changeset/neat-beds-unite.md @@ -0,0 +1,20 @@ +--- +"remix": minor +"@remix-run/cloudflare": minor +"@remix-run/deno": minor +"@remix-run/node": minor +"@remix-run/react": minor +"@remix-run/serve": minor +"@remix-run/server-runtime": minor +--- + +Each runtime package (@remix-run/cloudflare,@remix-run/deno,@remix-run/node) now exports `SerializeFrom`, which is used to +infer the JSON-serialized return type of loaders and actions. + +Example: +```ts +type MyLoaderData = SerializeFrom +type MyActionData = SerializeFrom +``` + +This is what `useLoaderData` and `useActionData` use under-the-hood. diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts index 0014967db94..e695c246ffe 100644 --- a/packages/remix-cloudflare/index.ts +++ b/packages/remix-cloudflare/index.ts @@ -32,7 +32,6 @@ export type { CookieParseOptions, CookieSerializeOptions, CookieSignatureOptions, - CreateRequestHandlerFunction, DataFunctionArgs, EntryContext, ErrorBoundaryComponent, @@ -53,12 +52,16 @@ export type { RequestHandler, RouteComponent, RouteHandle, + SerializeFrom, ServerBuild, ServerEntryModule, Session, SessionData, SessionIdStorageStrategy, SessionStorage, - UploadHandler, + SignFunction, + TypedResponse, + UnsignFunction, UploadHandlerPart, + UploadHandler, } from "@remix-run/server-runtime"; diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts index 13ce08afd21..be143a2beb2 100644 --- a/packages/remix-deno/index.ts +++ b/packages/remix-deno/index.ts @@ -35,7 +35,6 @@ export type { CookieParseOptions, CookieSerializeOptions, CookieSignatureOptions, - CreateRequestHandlerFunction, DataFunctionArgs, EntryContext, ErrorBoundaryComponent, @@ -56,12 +55,16 @@ export type { RequestHandler, RouteComponent, RouteHandle, + SerializeFrom, ServerBuild, ServerEntryModule, Session, SessionData, SessionIdStorageStrategy, SessionStorage, - UploadHandler, + SignFunction, + TypedResponse, + UnsignFunction, UploadHandlerPart, + UploadHandler, } from "@remix-run/server-runtime"; diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts index 03adb7aab93..01098e494cb 100644 --- a/packages/remix-node/index.ts +++ b/packages/remix-node/index.ts @@ -42,7 +42,6 @@ export { isSession, json, JsonFunction, - TypedResponse, MaxPartSizeExceededError, redirect, unstable_composeUploadHandlers, @@ -60,7 +59,6 @@ export type { CookieParseOptions, CookieSerializeOptions, CookieSignatureOptions, - CreateRequestHandlerFunction, DataFunctionArgs, EntryContext, ErrorBoundaryComponent, @@ -81,12 +79,16 @@ export type { RequestHandler, RouteComponent, RouteHandle, + SerializeFrom, ServerBuild, ServerEntryModule, Session, SessionData, SessionIdStorageStrategy, SessionStorage, - UploadHandler, + SignFunction, + TypedResponse, + UnsignFunction, UploadHandlerPart, + UploadHandler, } from "@remix-run/server-runtime"; diff --git a/packages/remix-react/__tests__/hook-types-test.tsx b/packages/remix-react/__tests__/hook-types-test.tsx index acd2c96b0f4..b6f0fe03a17 100644 --- a/packages/remix-react/__tests__/hook-types-test.tsx +++ b/packages/remix-react/__tests__/hook-types-test.tsx @@ -1,19 +1,24 @@ -import type { TypedResponse, UseDataFunctionReturn } from "../components"; +import type { TypedResponse } from "../serialize"; +import type { useLoaderData } from "../components"; function isEqual( arg: A extends B ? (B extends A ? true : false) : false ): void {} +// not sure why `eslint` thinks the `T` generic is not used... +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type LoaderData = ReturnType>; + describe("useLoaderData", () => { it("supports plain data type", () => { type AppData = { hello: string }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports plain Response", () => { type Loader = (args: any) => Response; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); @@ -21,31 +26,31 @@ describe("useLoaderData", () => { type Loader = ( args: any ) => TypedResponse<{ id: string }> | TypedResponse; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports Response-returning loader", () => { type Loader = (args: any) => TypedResponse<{ hello: string }>; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports async Response-returning loader", () => { type Loader = (args: any) => Promise>; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports data-returning loader", () => { type Loader = (args: any) => { hello: string }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports async data-returning loader", () => { type Loader = (args: any) => Promise<{ hello: string }>; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); }); @@ -53,26 +58,26 @@ describe("useLoaderData", () => { describe("type serializer", () => { it("converts Date to string", () => { type AppData = { hello: Date }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports custom toJSON", () => { type AppData = { toJSON(): { data: string[] } }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports recursion", () => { type AppData = { dob: Date; parent: AppData }; type SerializedAppData = { dob: string; parent: SerializedAppData }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("supports tuples and arrays", () => { type AppData = { arr: Date[]; tuple: [string, number, Date]; empty: [] }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual< response, { arr: string[]; tuple: [string, number, string]; empty: [] } @@ -81,13 +86,13 @@ describe("type serializer", () => { it("transforms unserializables to null in arrays", () => { type AppData = [Function, symbol, undefined]; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); it("transforms unserializables to never in objects", () => { type AppData = { arg1: Function; arg2: symbol; arg3: undefined }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); @@ -97,7 +102,7 @@ describe("type serializer", () => { speak: () => string; } type Loader = (args: any) => TypedResponse; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); @@ -107,7 +112,7 @@ describe("type serializer", () => { arg2: number | undefined; arg3: undefined; }; - type response = UseDataFunctionReturn; + type response = LoaderData; isEqual(true); }); }); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index ae5615a60c6..8a59d4c67f3 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -20,8 +20,8 @@ import { useResolvedPath, } from "react-router-dom"; import type { LinkProps, NavLinkProps } from "react-router-dom"; -import type { Merge } from "type-fest"; import { createPath } from "history"; +import type { SerializeFrom } from "@remix-run/server-runtime"; import type { AppData, FormEncType, FormMethod } from "./data"; import type { EntryContext, AssetsManifest } from "./entry"; @@ -1361,77 +1361,7 @@ export function useMatches(): RouteMatch[] { * * @see https://remix.run/api/remix#useloaderdata */ - -export type TypedResponse = Response & { - json(): Promise; -}; - -type DataFunction = (...args: any[]) => unknown; // matches any function -type DataOrFunction = AppData | DataFunction; -type JsonPrimitives = - | string - | number - | boolean - | String - | Number - | Boolean - | null; -type NonJsonPrimitives = undefined | Function | symbol; - -type SerializeType = T extends JsonPrimitives - ? T - : T extends NonJsonPrimitives - ? never - : T extends { toJSON(): infer U } - ? U - : T extends [] - ? [] - : T extends [unknown, ...unknown[]] - ? { - [k in keyof T]: T[k] extends NonJsonPrimitives - ? null - : SerializeType; - } - : T extends ReadonlyArray - ? (U extends NonJsonPrimitives ? null : SerializeType)[] - : T extends object - ? SerializeObject> - : never; - -type SerializeObject = { - [k in keyof T as T[k] extends NonJsonPrimitives ? never : k]: SerializeType< - T[k] - >; -}; - -/* - * For an object T, if it has any properties that are a union with `undefined`, - * make those into optional properties instead. - * - * Example: { a: string | undefined} --> { a?: string} - */ -type UndefinedOptionals = Merge< - { - // Property is not a union with `undefined`, keep as-is - [k in keyof T as undefined extends T[k] ? never : k]: T[k]; - }, - { - // Property _is_ a union with `defined`. Set as optional (via `?`) and remove `undefined` from the union - [k in keyof T as undefined extends T[k] ? k : never]?: Exclude< - T[k], - undefined - >; - } ->; - -export type UseDataFunctionReturn = T extends ( - ...args: any[] -) => infer Output - ? Awaited extends TypedResponse - ? SerializeType - : SerializeType>> - : SerializeType>; -export function useLoaderData(): UseDataFunctionReturn { +export function useLoaderData(): SerializeFrom { return useRemixRouteContext().data; } @@ -1440,9 +1370,7 @@ export function useLoaderData(): UseDataFunctionReturn { * * @see https://remix.run/api/remix#useactiondata */ -export function useActionData(): - | UseDataFunctionReturn - | undefined { +export function useActionData(): SerializeFrom | undefined { let { id: routeId } = useRemixRouteContext(); let { transitionManager } = useRemixEntryContext(); let { actionData } = transitionManager.getState(); diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 7d8d08d0140..d5e9ab039be 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -21,6 +21,7 @@ "type-fest": "^2.17.0" }, "devDependencies": { + "@remix-run/server-runtime": "*", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^13.3.0", "abort-controller": "^3.0.0", diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index 7a9b2d63328..400c3b89351 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -28,7 +28,6 @@ export type { IsSessionFunction, JsonFunction, RedirectFunction, - TypedResponse, } from "./interface"; // Remix server runtime packages should re-export these types @@ -54,12 +53,15 @@ export type { LinksFunction, LoaderArgs, LoaderFunction, + MemoryUploadHandlerFilterArgs, + MemoryUploadHandlerOptions, MetaDescriptor, MetaFunction, PageLinkDescriptor, RequestHandler, RouteComponent, RouteHandle, + SerializeFrom, ServerBuild, ServerEntryModule, Session, @@ -67,9 +69,8 @@ export type { SessionIdStorageStrategy, SessionStorage, SignFunction, + TypedResponse, UnsignFunction, UploadHandlerPart, UploadHandler, - MemoryUploadHandlerOptions, - MemoryUploadHandlerFilterArgs, } from "./reexport"; diff --git a/packages/remix-server-runtime/interface.ts b/packages/remix-server-runtime/interface.ts index ba88aac63ec..be54c937c35 100644 --- a/packages/remix-server-runtime/interface.ts +++ b/packages/remix-server-runtime/interface.ts @@ -1,9 +1,5 @@ export type { CreateCookieFunction, IsCookieFunction } from "./cookies"; -export type { - JsonFunction, - RedirectFunction, - TypedResponse, -} from "./responses"; +export type { JsonFunction, RedirectFunction } from "./responses"; export type { CreateRequestHandlerFunction } from "./server"; export type { CreateSessionFunction, diff --git a/packages/remix-server-runtime/reexport.ts b/packages/remix-server-runtime/reexport.ts index e6e360ba1c7..ccd50d894cd 100644 --- a/packages/remix-server-runtime/reexport.ts +++ b/packages/remix-server-runtime/reexport.ts @@ -31,6 +31,8 @@ export type { PageLinkDescriptor, } from "./links"; +export type { TypedResponse } from "./responses"; + export type { ActionArgs, ActionFunction, @@ -47,6 +49,8 @@ export type { RouteHandle, } from "./routeModules"; +export type { SerializeFrom } from "./serialize"; + export type { RequestHandler } from "./server"; export type { diff --git a/packages/remix-server-runtime/serialize.ts b/packages/remix-server-runtime/serialize.ts new file mode 100644 index 00000000000..08802c1c873 --- /dev/null +++ b/packages/remix-server-runtime/serialize.ts @@ -0,0 +1,78 @@ +import type { Merge } from "type-fest"; + +import type { AppData } from "./data"; +import type { TypedResponse } from "./responses"; + +type JsonPrimitive = + | string + | number + | boolean + | String + | Number + | Boolean + | null; +type NonJsonPrimitive = undefined | Function | symbol; + +/* + * `any` is the only type that can let you equate `0` with `1` + * See https://stackoverflow.com/a/49928360/1490091 + */ +type IsAny = 0 extends 1 & T ? true : false; + +// prettier-ignore +type Serialize = + IsAny extends true ? any : + T extends JsonPrimitive ? T : + T extends NonJsonPrimitive ? never : + T extends { toJSON(): infer U } ? U : + T extends [] ? [] : + T extends [unknown, ...unknown[]] ? SerializeTuple : + T extends ReadonlyArray ? (U extends NonJsonPrimitive ? null : Serialize)[] : + T extends object ? SerializeObject> : + never; + +/** JSON serialize [tuples](https://www.typescriptlang.org/docs/handbook/2/objects.html#tuple-types) */ +type SerializeTuple = { + [k in keyof T]: T[k] extends NonJsonPrimitive ? null : Serialize; +}; + +/** JSON serialize objects (not including arrays) and classes */ +type SerializeObject = { + [k in keyof T as T[k] extends NonJsonPrimitive ? never : k]: Serialize; +}; + +/* + * For an object T, if it has any properties that are a union with `undefined`, + * make those into optional properties instead. + * + * Example: { a: string | undefined} --> { a?: string} + */ +type UndefinedToOptional = Merge< + { + // Property is not a union with `undefined`, keep as-is + [k in keyof T as undefined extends T[k] ? never : k]: T[k]; + }, + { + // Property _is_ a union with `defined`. Set as optional (via `?`) and remove `undefined` from the union + [k in keyof T as undefined extends T[k] ? k : never]?: Exclude< + T[k], + undefined + >; + } +>; + +type ArbitraryFunction = (...args: any[]) => unknown; + +/** + * Infer JSON serialized data type returned by a loader or action. + * + * For example: + * `type LoaderData = SerializeFrom` + */ +export type SerializeFrom = Serialize< + T extends (...args: any[]) => infer Output + ? Awaited extends TypedResponse + ? U + : Awaited + : Awaited +>;