Skip to content

Commit

Permalink
Add compose helper
Browse files Browse the repository at this point in the history
  • Loading branch information
fenok committed Jan 20, 2023
1 parent a8e7889 commit 4082bad
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 70 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add support for optional path segments.
- Add `compose` helper for route types composition.
- Add `getUntypedParams()` method to route object.

### Fixed
Expand Down
114 changes: 59 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,60 @@ Child routes inherit all type objects from their parent. For parameters with the
Hash values are combined. If a parent allows any `string` to be a hash value, its children can't override that.
#### Type composition
It's pretty common to have completely unrelated routes that share the same set of params. One such example is pagination params.
We can use nesting and put common types to a single common route:
```typescript jsx
const ROUTE = route(
"",
{ searchParams: { page: numberType } },
{ USER: route("user"), POST: route("post"), ABOUT: route("about") }
);

// We can use this common ROUTE to get the page param anywhere:
const [{ page }] = useTypedSearchParams(ROUTE);
```
However, this approach has the following drawbacks:
- All routes will have all common params, even if they don't actually need them.
- Common params are lost upon child uninlining.
- All common params are defined in one place, which may get cluttered.
- We can't share path params this way, because they require the corresponding path pattern.
```typescript jsx
// This is allowed, but makes no sense, since there is no pagination on About page.
ROUTE.ABOUT.buildPath({}, { page: 1 });

// This won't work, but we actually need this param.
ROUTE.$.POST.buildPath({}, { page: 1 });
```
To mitigate these issues, we can use type composition via the `compose` helper:
```typescript jsx
const PAGINATION_FRAGMENT = route("", { searchParams: { page: numberType } });

const ROUTES = {
// This route uses pagination params and also has its own search params.
USER: route("user", compose({ searchParams: { showRemoved: booleanType } })(PAGINATION_FRAGMENT)),
// This route only uses pagination params.
POST: route("post", compose(PAGINATION_FRAGMENT)),
// This route doesn't use pagination params
ABOUT: route("about"),
};

// We can use PAGINATION_FRAGMENT to get the page param anywhere:
const [{ page }] = useTypedSearchParams(PAGINATION_FRAGMENT);
```
The `compose` helper accepts either a set of type objects, or a route which type objects should be used, and returns a callable set of type objects, which can be called to add more types. We can compose any number of types. For params with the same name, the latest type in the chain has precedence.
> ❗ Types for path params will only be used if path pattern has corresponding dynamic segments.
## API
### `route()`
Expand Down Expand Up @@ -411,10 +465,14 @@ The `route()` helper returns a route object, which has the following fields:
- `getTypedParams()`, `getTypedSearchParams()`, `getTypedHash()`, and `getTypedState()` for retrieving typed params from React Router primitives. Untyped params are omitted.
- `getUntypedParams()`, `getUntypedSearchParams()`, and `getUntypedState()` for retrieving untyped params from React Router primitives. Typed params are omitted. Note that hash is always typed.
- `getPlainParams()` and `getPlainSearchParams()` for building React Router primitives from typed params. Note how hash and state don't need these functions, because `buildHash()` and `buildState()` can be used instead.
- `types`, which contains type objects of the route, including parent types, if any. Can be used for sharing type objects with other routes.
- `types`, which contains type objects of the route, including parent types, if any. Can be used for sharing type objects with other routes, though normally you should use the [`compose()`](#compose) helper instead of direct use of `types`.
- `$`, which contains original child routes. These routes are unaffected by the parent route.
- Any number of child routes starting with an uppercase letter.
### `compose()`
The `compose()` helper is used for type composition. See [Type composition](#type-composition).
### Built-in types
- `stringType` - `string`, stringified as-is.
Expand Down Expand Up @@ -561,57 +619,3 @@ export const yupStringType = <TSchema extends Schema>(schema: TSchema) => {
});
};
```
### Sharing types between routes
It's pretty common to have completely unrelated routes that share the same set of params. One such example is pagination.
#### Inheritance
The easiest way to share types between unrelated routes is to put them to single common route:
```typescript jsx
const ROUTE = route(
"",
{ searchParams: { page: numberType } },
{ USER: route("user"), POST: route("post"), ABOUT: route("about") }
);
// We can use this common ROUTE to get the page param anywhere:
const [{ page }] = useTypedSearchParams(ROUTE);
```
However, this approach have the following drawbacks:
- All routes will have all common params, even if they don't actually need them.
- Common params are lost upon child uninlining.
- All common params are defined in one place, which may get cluttered.
- We can't share path params this way, because they require the corresponding path pattern.
```typescript jsx
// This is allowed, but makes no sense, since there is no pagination on About page.
ROUTE.ABOUT.buildPath({}, { page: 1 });
// This won't work, but we actually need this param.
ROUTE.$.POST.buildPath({}, { page: 1 });
```
#### Composition
This drawbacks can be solved by route composition:
```typescript jsx
// We could also make such fragment for path params
const PAGINATION_FRAGMENT = route("", { searchParams: { page: numberType } });
const ROUTES = {
USER: route("user", { searchParams: { ...PAGINATION_FRAGMENT.types.searchParams } }),
POST: route("post", { searchParams: { ...PAGINATION_FRAGMENT.types.searchParams } }),
ABOUT: route("about"),
};
// We can use PAGINATION_FRAGMENT to get the page param anywhere:
const [{ page }] = useTypedSearchParams(PAGINATION_FRAGMENT);
```
This API is far from prefect, but I'm not sure how to implement a proper [Composition API](https://github.com/fenok/react-router-typesafe-routes/issues/13).
54 changes: 54 additions & 0 deletions src/common/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { RouteTypes } from "./createRoute/index.js";
import { mergeHashValues } from "./mergeHashValues.js";

export type Compose<TPathTypes, TSearchTypes, THash extends string[], TStateTypes> = RouteTypes<
TPathTypes,
TSearchTypes,
THash,
TStateTypes
> &
(<TChildPathTypes, TChildSearchTypes, TChildHash extends string[], TChildStateTypes>(
types:
| {
types: RouteTypes<TChildPathTypes, TChildSearchTypes, TChildHash, TChildStateTypes>;
}
| RouteTypes<TChildPathTypes, TChildSearchTypes, TChildHash, TChildStateTypes>
) => Compose<
TPathTypes & TChildPathTypes,
TSearchTypes & TChildSearchTypes,
THash | TChildHash,
TStateTypes & TChildStateTypes
>);

export function compose<TPathTypes, TSearchTypes, THash extends string[], TStateTypes>(
types:
| {
types: RouteTypes<TPathTypes, TSearchTypes, THash, TStateTypes>;
}
| RouteTypes<TPathTypes, TSearchTypes, THash, TStateTypes>
): Compose<TPathTypes, TSearchTypes, THash, TStateTypes> {
const normalizedTypes: RouteTypes<TPathTypes, TSearchTypes, THash, TStateTypes> =
"types" in types ? types.types : types;

const result = <TChildPathTypes, TChildSearchTypes, TChildHash extends string[], TChildStateTypes>(
childTypes:
| {
types: RouteTypes<TChildPathTypes, TChildSearchTypes, TChildHash, TChildStateTypes>;
}
| RouteTypes<TChildPathTypes, TChildSearchTypes, TChildHash, TChildStateTypes>
) => {
const normalizedChildTypes: RouteTypes<TChildPathTypes, TChildSearchTypes, TChildHash, TChildStateTypes> =
"types" in childTypes ? childTypes.types : childTypes;

return compose({
types: {
params: { ...normalizedTypes.params, ...normalizedChildTypes.params },
searchParams: { ...normalizedTypes.searchParams, ...normalizedChildTypes.searchParams },
state: { ...normalizedTypes.state, ...normalizedChildTypes.state },
hash: mergeHashValues(normalizedTypes.hash, normalizedChildTypes.hash),
},
});
};

return Object.assign(result, normalizedTypes) as Compose<TPathTypes, TSearchTypes, THash, TStateTypes>;
}
17 changes: 3 additions & 14 deletions src/common/createRoute/createRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ThrowableFallback,
} from "../types/index.js";
import { warn } from "../warn.js";
import { mergeHashValues } from "../mergeHashValues.js";

type RouteWithChildren<
TChildren,
Expand All @@ -30,15 +31,15 @@ type DecoratedChildren<
infer TChildChildren,
infer TChildPath,
infer TChildPathTypes,
infer TChildQueryTypes,
infer TChildSearchTypes,
infer TChildHash,
infer TChildStateTypes
>
? RouteWithChildren<
TChildChildren,
TPath extends "" ? TChildPath : TChildPath extends "" ? TPath : `${TPath}/${TChildPath}`,
TPathTypes & TChildPathTypes,
TSearchTypes & TChildQueryTypes,
TSearchTypes & TChildSearchTypes,
THash | TChildHash,
TStateTypes & TChildStateTypes
>
Expand Down Expand Up @@ -604,18 +605,6 @@ function removeIntermediateStars<TPath extends string>(path: TPath): PathWithout
return path.replace(/\*\??\//g, "") as PathWithoutIntermediateStars<TPath>;
}

function mergeHashValues<T, U>(firstHash?: T[], secondHash?: U[]): (T | U)[] | undefined {
if (!firstHash && !secondHash) {
return undefined;
}

if (firstHash?.length === 0 || secondHash?.length === 0) {
return [];
}

return [...(firstHash ?? []), ...(secondHash ?? [])];
}

function isRoute(
value: unknown
): value is RouteWithChildren<
Expand Down
1 change: 1 addition & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./createRoute/index.js";
export * from "./types/index.js";
export * from "./hashValues.js";
export * from "./compose.js";
11 changes: 11 additions & 0 deletions src/common/mergeHashValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function mergeHashValues<T, U>(firstHash?: T[], secondHash?: U[]): (T | U)[] | undefined {
if (!firstHash && !secondHash) {
return undefined;
}

if (firstHash?.length === 0 || secondHash?.length === 0) {
return [];
}

return [...(firstHash ?? []), ...(secondHash ?? [])];
}
49 changes: 48 additions & 1 deletion src/dom/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { route } from "./route.js";
import { createSearchParams } from "react-router-dom";
import { numberType, booleanType, arrayOfType, stringType, hashValues, throwable, dateType } from "../common/index.js";
import {
numberType,
booleanType,
arrayOfType,
stringType,
hashValues,
throwable,
dateType,
compose,
} from "../common/index.js";
import { assert, IsExact } from "conditional-type-checks";

it("allows to uninline children", () => {
Expand Down Expand Up @@ -927,3 +936,41 @@ it("throws if throwable state params are invalid", () => {
it("throws upon specifying an invalid fallback", () => {
expect(() => route("", { searchParams: { id: dateType(new Date("foo")) } })).toThrow();
});

it("allows types composition", () => {
const PATH = route(":id", { params: { id: numberType } });
const SEARCH = route("", { searchParams: { page: numberType } });
const STATE = route("", { state: { fromList: booleanType } });
const HASH = route("", { hash: hashValues("about", "more") });

const ROUTE = route(
":id/:subId",
compose({
params: {
subId: numberType,
},
searchParams: {
ordered: booleanType,
page: booleanType, // This should be overridden
},
state: {
hidden: booleanType,
},
hash: hashValues("info"),
})(PATH)(SEARCH)(STATE)(HASH)
);

assert<IsExact<Parameters<typeof ROUTE.buildPath>[0], { id: number; subId: number }>>(true);

assert<IsExact<Parameters<typeof ROUTE.buildPath>[1], { page?: number; ordered?: boolean } | undefined>>(true);

assert<IsExact<Parameters<typeof ROUTE.buildPath>[2], "about" | "more" | "info" | undefined>>(true);

assert<IsExact<Parameters<typeof ROUTE.buildState>[0], { fromList?: boolean; hidden?: boolean }>>(true);

expect(ROUTE.buildPath({ id: 1, subId: 2 }, { page: 1, ordered: true }, "info")).toEqual(
"1/2?page=1&ordered=true#info"
);

expect(ROUTE.buildState({ fromList: true, hidden: true })).toStrictEqual({ fromList: "true", hidden: "true" });
});

0 comments on commit 4082bad

Please sign in to comment.