Skip to content

Commit d0dba01

Browse files
committed
feat(react-router): add type safety to useActionData & useLoaderData hooks
1 parent 0791787 commit d0dba01

File tree

3 files changed

+128
-2
lines changed

3 files changed

+128
-2
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* @see https://github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts
3+
*/
4+
5+
declare const emptyObjectSymbol: unique symbol;
6+
type EmptyObject = { [emptyObjectSymbol]?: never };
7+
8+
type IsAny<T> = 0 extends 1 & T ? true : false;
9+
10+
type JsonArray = JsonValue[];
11+
type JsonObject = { [Key in string]: JsonValue } & {
12+
[Key in string]?: JsonValue | undefined;
13+
};
14+
type JsonPrimitive = string | number | boolean | null;
15+
type JsonValue = JsonPrimitive | JsonObject | JsonArray;
16+
17+
type NegativeInfinity = -1e999;
18+
type PositiveInfinity = 1e999;
19+
20+
type TypedArray =
21+
| Int8Array
22+
| Uint8Array
23+
| Uint8ClampedArray
24+
| Int16Array
25+
| Uint16Array
26+
| Int32Array
27+
| Uint32Array
28+
| Float32Array
29+
| Float64Array
30+
| BigInt64Array
31+
| BigUint64Array;
32+
33+
type BaseKeyFilter<Type, Key extends keyof Type> = Key extends symbol
34+
? never
35+
: Type[Key] extends symbol
36+
? never
37+
: [(...args: any[]) => any] extends [Type[Key]]
38+
? never
39+
: Key;
40+
type FilterDefinedKeys<T extends object> = Exclude<
41+
{
42+
[Key in keyof T]: IsAny<T[Key]> extends true
43+
? Key
44+
: undefined extends T[Key]
45+
? never
46+
: T[Key] extends undefined
47+
? never
48+
: BaseKeyFilter<T, Key>;
49+
}[keyof T],
50+
undefined
51+
>;
52+
type FilterOptionalKeys<T extends object> = Exclude<
53+
{
54+
[Key in keyof T]: IsAny<T[Key]> extends true
55+
? never
56+
: undefined extends T[Key]
57+
? T[Key] extends undefined
58+
? never
59+
: BaseKeyFilter<T, Key>
60+
: never;
61+
}[keyof T],
62+
undefined
63+
>;
64+
type UndefinedToOptional<T extends object> = {
65+
// Property is not a union with `undefined`, keep it as-is.
66+
[Key in keyof Pick<T, FilterDefinedKeys<T>>]: T[Key];
67+
} & {
68+
// Property _is_ a union with defined value. Set as optional (via `?`) and remove `undefined` from the union.
69+
[Key in keyof Pick<T, FilterOptionalKeys<T>>]?: Exclude<T[Key], undefined>;
70+
};
71+
72+
// Note: The return value has to be `any` and not `unknown` so it can match `void`.
73+
type NotJsonable = ((...args: any[]) => any) | undefined | symbol;
74+
75+
type JsonifyTuple<T extends [unknown, ...unknown[]]> = {
76+
[Key in keyof T]: T[Key] extends NotJsonable ? null : Jsonify<T[Key]>;
77+
};
78+
79+
type FilterJsonableKeys<T extends object> = {
80+
[Key in keyof T]: T[Key] extends NotJsonable ? never : Key;
81+
}[keyof T];
82+
83+
type JsonifyObject<T extends object> = {
84+
[Key in keyof Pick<T, FilterJsonableKeys<T>>]: Jsonify<T[Key]>;
85+
};
86+
87+
// prettier-ignore
88+
export type Jsonify<T> =
89+
IsAny<T> extends true ? any
90+
: T extends PositiveInfinity | NegativeInfinity ? null
91+
: T extends JsonPrimitive ? T
92+
// Instanced primitives are objects
93+
: T extends Number ? number
94+
: T extends String ? string
95+
: T extends Boolean ? boolean
96+
: T extends Map<any, any> | Set<any> ? EmptyObject
97+
: T extends TypedArray ? Record<string, number>
98+
: T extends NotJsonable ? never // Non-JSONable type union was found not empty
99+
// Any object with toJSON is special case
100+
: T extends { toJSON(): infer J } ?
101+
(() => J) extends () => JsonValue // Is J assignable to JsonValue?
102+
? J // Then T is Jsonable and its Jsonable value is J
103+
: Jsonify<J> // Maybe if we look a level deeper we'll find a JsonValue
104+
: T extends [] ? []
105+
: T extends [unknown, ...unknown[]] ? JsonifyTuple<T>
106+
: T extends ReadonlyArray<infer U> ? Array<U extends NotJsonable ? null : Jsonify<U>>
107+
: T extends object ? JsonifyObject<UndefinedToOptional<T>> // JsonifyObject recursive call for its children
108+
: never; // Otherwise any other non-object is removed

packages/react-router/lib/hooks.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
RouteErrorContext,
4242
AwaitContext,
4343
} from "./context";
44+
import type { SerializeFrom } from "./serialize";
45+
import type { ArbitraryFunction } from "./serialize";
4446

4547
/**
4648
* Returns the full href for the given "to" value. This is useful for building
@@ -799,7 +801,9 @@ export function useMatches() {
799801
/**
800802
* Returns the loader data for the nearest ancestor Route loader
801803
*/
802-
export function useLoaderData(): unknown {
804+
export function useLoaderData<
805+
T extends ArbitraryFunction = () => unknown
806+
>(): SerializeFrom<T> {
803807
let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
804808
let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);
805809

@@ -823,7 +827,9 @@ export function useRouteLoaderData(routeId: string): unknown {
823827
/**
824828
* Returns the action data for the nearest ancestor Route action
825829
*/
826-
export function useActionData(): unknown {
830+
export function useActionData<
831+
T extends ArbitraryFunction = () => unknown
832+
>(): SerializeFrom<T> {
827833
let state = useDataRouterState(DataRouterStateHook.UseActionData);
828834

829835
let route = React.useContext(RouteContext);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { TypedResponse } from "@remix-run/router";
2+
import type { Jsonify } from "./jsonify";
3+
4+
export type ArbitraryFunction = (...args: any[]) => unknown;
5+
6+
export type SerializeFrom<T extends ArbitraryFunction> = Jsonify<
7+
T extends (...args: any[]) => infer Output
8+
? Awaited<Output> extends TypedResponse<infer U>
9+
? U
10+
: Awaited<Output>
11+
: Awaited<T>
12+
>;

0 commit comments

Comments
 (0)