Skip to content

Commit

Permalink
feat(react-router): add type safety to useActionData & `useLoaderDa…
Browse files Browse the repository at this point in the history
…ta` hooks
  • Loading branch information
MichaelDeBoey committed Apr 17, 2023
1 parent 0791787 commit d0dba01
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 2 deletions.
108 changes: 108 additions & 0 deletions packages/react-router/lib/Jsonify.ts
Original file line number Diff line number Diff line change
@@ -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<T> = 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<Type, Key extends keyof Type> = Key extends symbol
? never
: Type[Key] extends symbol
? never
: [(...args: any[]) => any] extends [Type[Key]]
? never
: Key;
type FilterDefinedKeys<T extends object> = Exclude<
{
[Key in keyof T]: IsAny<T[Key]> extends true
? Key
: undefined extends T[Key]
? never
: T[Key] extends undefined
? never
: BaseKeyFilter<T, Key>;
}[keyof T],
undefined
>;
type FilterOptionalKeys<T extends object> = Exclude<
{
[Key in keyof T]: IsAny<T[Key]> extends true
? never
: undefined extends T[Key]
? T[Key] extends undefined
? never
: BaseKeyFilter<T, Key>
: never;
}[keyof T],
undefined
>;
type UndefinedToOptional<T extends object> = {
// Property is not a union with `undefined`, keep it as-is.
[Key in keyof Pick<T, FilterDefinedKeys<T>>]: T[Key];
} & {
// Property _is_ a union with defined value. Set as optional (via `?`) and remove `undefined` from the union.
[Key in keyof Pick<T, FilterOptionalKeys<T>>]?: Exclude<T[Key], undefined>;
};

// 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<T extends [unknown, ...unknown[]]> = {
[Key in keyof T]: T[Key] extends NotJsonable ? null : Jsonify<T[Key]>;
};

type FilterJsonableKeys<T extends object> = {
[Key in keyof T]: T[Key] extends NotJsonable ? never : Key;
}[keyof T];

type JsonifyObject<T extends object> = {
[Key in keyof Pick<T, FilterJsonableKeys<T>>]: Jsonify<T[Key]>;
};

// prettier-ignore
export type Jsonify<T> =
IsAny<T> 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<any, any> | Set<any> ? EmptyObject
: T extends TypedArray ? Record<string, number>
: 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<J> // Maybe if we look a level deeper we'll find a JsonValue
: T extends [] ? []
: T extends [unknown, ...unknown[]] ? JsonifyTuple<T>
: T extends ReadonlyArray<infer U> ? Array<U extends NotJsonable ? null : Jsonify<U>>
: T extends object ? JsonifyObject<UndefinedToOptional<T>> // JsonifyObject recursive call for its children
: never; // Otherwise any other non-object is removed
10 changes: 8 additions & 2 deletions packages/react-router/lib/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T> {
let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);

Expand All @@ -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<T> {
let state = useDataRouterState(DataRouterStateHook.UseActionData);

let route = React.useContext(RouteContext);
Expand Down
12 changes: 12 additions & 0 deletions packages/react-router/lib/serialize.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ArbitraryFunction> = Jsonify<
T extends (...args: any[]) => infer Output
? Awaited<Output> extends TypedResponse<infer U>
? U
: Awaited<Output>
: Awaited<T>
>;

0 comments on commit d0dba01

Please sign in to comment.