From 07917876cca85e8d7ba7abb57126d41c59162a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Sat, 3 Dec 2022 01:31:33 +0100 Subject: [PATCH 1/2] feat(router): add type safety to `json` & `redirect` functions --- packages/router/index.ts | 1 + packages/router/utils.ts | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/router/index.ts b/packages/router/index.ts index 3d4fea9620..1f2a8fe7b5 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -24,6 +24,7 @@ export type { RedirectFunction, ShouldRevalidateFunction, V7_FormMethod, + TypedResponse, } from "./utils"; export { diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 0b8b18d8c7..f7f7628e60 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -1197,14 +1197,23 @@ export const normalizeSearch = (search: string): string => export const normalizeHash = (hash: string): string => !hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash; -export type JsonFunction = ( +export type JsonFunction = ( data: Data, init?: number | ResponseInit -) => Response; +) => TypedResponse; + +export type TypedResponse = Omit< + Response, + "json" +> & { + json(): Promise; +}; /** * This is a shortcut for creating `application/json` responses. Converts `data` * to JSON and sets the `Content-Type` header. + * + * @see https://reactrouter.com/fetch/json */ export const json: JsonFunction = (data, init = {}) => { let responseInit = typeof init === "number" ? { status: init } : init; @@ -1418,11 +1427,13 @@ export const defer: DeferFunction = (data, init = {}) => { export type RedirectFunction = ( url: string, init?: number | ResponseInit -) => Response; +) => TypedResponse; /** * A redirect response. Sets the status code and the `Location` header. * Defaults to "302 Found". + * + * @see https://reactrouter.com/fetch/redirect */ export const redirect: RedirectFunction = (url, init = 302) => { let responseInit = init; @@ -1438,7 +1449,7 @@ export const redirect: RedirectFunction = (url, init = 302) => { return new Response(null, { ...responseInit, headers, - }); + }) as TypedResponse; }; /** From d0dba01b1efe5f45e445b0df153b9403bbf2243b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Sat, 3 Dec 2022 02:13:00 +0100 Subject: [PATCH 2/2] feat(react-router): add type safety to `useActionData` & `useLoaderData` hooks --- packages/react-router/lib/Jsonify.ts | 108 +++++++++++++++++++++++++ packages/react-router/lib/hooks.tsx | 10 ++- packages/react-router/lib/serialize.ts | 12 +++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/react-router/lib/Jsonify.ts create mode 100644 packages/react-router/lib/serialize.ts diff --git a/packages/react-router/lib/Jsonify.ts b/packages/react-router/lib/Jsonify.ts new file mode 100644 index 0000000000..a1ab152cc2 --- /dev/null +++ b/packages/react-router/lib/Jsonify.ts @@ -0,0 +1,108 @@ +/** + * @see https://github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts + */ + +declare const emptyObjectSymbol: unique symbol; +type EmptyObject = { [emptyObjectSymbol]?: never }; + +type IsAny = 0 extends 1 & T ? true : false; + +type JsonArray = JsonValue[]; +type JsonObject = { [Key in string]: JsonValue } & { + [Key in string]?: JsonValue | undefined; +}; +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +type NegativeInfinity = -1e999; +type PositiveInfinity = 1e999; + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +type BaseKeyFilter = Key extends symbol + ? never + : Type[Key] extends symbol + ? never + : [(...args: any[]) => any] extends [Type[Key]] + ? never + : Key; +type FilterDefinedKeys = Exclude< + { + [Key in keyof T]: IsAny extends true + ? Key + : undefined extends T[Key] + ? never + : T[Key] extends undefined + ? never + : BaseKeyFilter; + }[keyof T], + undefined +>; +type FilterOptionalKeys = Exclude< + { + [Key in keyof T]: IsAny extends true + ? never + : undefined extends T[Key] + ? T[Key] extends undefined + ? never + : BaseKeyFilter + : never; + }[keyof T], + undefined +>; +type UndefinedToOptional = { + // Property is not a union with `undefined`, keep it as-is. + [Key in keyof Pick>]: T[Key]; +} & { + // Property _is_ a union with defined value. Set as optional (via `?`) and remove `undefined` from the union. + [Key in keyof Pick>]?: Exclude; +}; + +// Note: The return value has to be `any` and not `unknown` so it can match `void`. +type NotJsonable = ((...args: any[]) => any) | undefined | symbol; + +type JsonifyTuple = { + [Key in keyof T]: T[Key] extends NotJsonable ? null : Jsonify; +}; + +type FilterJsonableKeys = { + [Key in keyof T]: T[Key] extends NotJsonable ? never : Key; +}[keyof T]; + +type JsonifyObject = { + [Key in keyof Pick>]: Jsonify; +}; + +// prettier-ignore +export type Jsonify = + IsAny extends true ? any + : T extends PositiveInfinity | NegativeInfinity ? null + : T extends JsonPrimitive ? T + // Instanced primitives are objects + : T extends Number ? number + : T extends String ? string + : T extends Boolean ? boolean + : T extends Map | Set ? EmptyObject + : T extends TypedArray ? Record + : T extends NotJsonable ? never // Non-JSONable type union was found not empty + // Any object with toJSON is special case + : T extends { toJSON(): infer J } ? + (() => J) extends () => JsonValue // Is J assignable to JsonValue? + ? J // Then T is Jsonable and its Jsonable value is J + : Jsonify // Maybe if we look a level deeper we'll find a JsonValue + : T extends [] ? [] + : T extends [unknown, ...unknown[]] ? JsonifyTuple + : T extends ReadonlyArray ? Array> + : T extends object ? JsonifyObject> // JsonifyObject recursive call for its children + : never; // Otherwise any other non-object is removed diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 4568e09880..0a30fb5643 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -41,6 +41,8 @@ import { RouteErrorContext, AwaitContext, } from "./context"; +import type { SerializeFrom } from "./serialize"; +import type { ArbitraryFunction } from "./serialize"; /** * Returns the full href for the given "to" value. This is useful for building @@ -799,7 +801,9 @@ export function useMatches() { /** * Returns the loader data for the nearest ancestor Route loader */ -export function useLoaderData(): unknown { +export function useLoaderData< + T extends ArbitraryFunction = () => unknown +>(): SerializeFrom { let state = useDataRouterState(DataRouterStateHook.UseLoaderData); let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData); @@ -823,7 +827,9 @@ export function useRouteLoaderData(routeId: string): unknown { /** * Returns the action data for the nearest ancestor Route action */ -export function useActionData(): unknown { +export function useActionData< + T extends ArbitraryFunction = () => unknown +>(): SerializeFrom { let state = useDataRouterState(DataRouterStateHook.UseActionData); let route = React.useContext(RouteContext); diff --git a/packages/react-router/lib/serialize.ts b/packages/react-router/lib/serialize.ts new file mode 100644 index 0000000000..8a17a15096 --- /dev/null +++ b/packages/react-router/lib/serialize.ts @@ -0,0 +1,12 @@ +import type { TypedResponse } from "@remix-run/router"; +import type { Jsonify } from "./jsonify"; + +export type ArbitraryFunction = (...args: any[]) => unknown; + +export type SerializeFrom = Jsonify< + T extends (...args: any[]) => infer Output + ? Awaited extends TypedResponse + ? U + : Awaited + : Awaited +>;