├── .changeset
├── README.md
└── config.json
├── .eslintrc.js
├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── .npmrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── SECURITY.md
├── package-lock.json
├── package.json
├── packages
├── eslint-config
│ ├── README.md
│ ├── library.js
│ ├── next.js
│ ├── package.json
│ └── react-internal.js
├── type-safe-paths
│ ├── .eslintrc.js
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ └── tests.ts
│ ├── tsconfig.json
│ └── tsconfig.lint.json
└── typescript-config
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
├── prettier.config.cjs
├── tsconfig.json
└── turbo.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // This configuration only applies to the package manager root.
2 | /** @type {import("eslint").Linter.Config} */
3 | module.exports = {
4 | ignorePatterns: ["examples/**", "packages/**"],
5 | extends: ["@repo/eslint-config/library.js"],
6 | parser: "@typescript-eslint/parser",
7 | parserOptions: {
8 | project: true,
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout Repo
16 | uses: actions/checkout@v3
17 |
18 | - name: Setup Node.js 20.x
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: 20.x
22 |
23 | - name: Install Dependencies
24 | run: npm i
25 |
26 | - name: Create Release Pull Request or Publish to npm
27 | id: changesets
28 | uses: changesets/action@v1
29 | with:
30 | publish: npm run publish-packages
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # Local env files
9 | .env
10 | .env.local
11 | .env.development.local
12 | .env.test.local
13 | .env.production.local
14 |
15 | # Testing
16 | coverage
17 |
18 | # Turbo
19 | .turbo
20 |
21 | # Vercel
22 | .vercel
23 |
24 | # Build Outputs
25 | .next/
26 | out/
27 | build
28 | dist
29 |
30 |
31 | # Debug
32 | npm-debug.log*
33 | yarn-debug.log*
34 | yarn-error.log*
35 |
36 | # Misc
37 | .DS_Store
38 | *.pem
39 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/IdoPesok/type-safe-paths/79860f335c3533e315587daf23f72cab009a91d8/.npmrc
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | { "pattern": "examples/*/" },
4 | { "pattern": "packages/*/" }
5 | ],
6 | "tailwindCSS.experimental.classRegex": [
7 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
8 | ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024 Ido Pesok and others
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | packages/type-safe-paths/README.md
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | If you have a security issue to report, please contact [idopesok91@gmail.com](mailto:idopesok91@gmail.com).
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "type-safe-paths-root",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo build",
6 | "dev": "turbo dev",
7 | "lint": "turbo lint",
8 | "typecheck": "turbo typecheck",
9 | "format": "prettier --write \"**/*.{ts,tsx,md}\"",
10 | "format:write": "turbo run format:write",
11 | "format:check": "turbo run format:check",
12 | "postinstall": "manypkg check",
13 | "publish-packages": "npm run build && changeset version && changeset publish",
14 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next"
15 | },
16 | "engines": {
17 | "node": ">=18"
18 | },
19 | "packageManager": "npm@10.2.3",
20 | "workspaces": [
21 | "examples/*",
22 | "packages/*"
23 | ],
24 | "dependencies": {
25 | "@changesets/cli": "^2.27.1",
26 | "@ianvs/prettier-plugin-sort-imports": "^4.2.1",
27 | "@manypkg/cli": "^0.21.4",
28 | "@repo/eslint-config": "*",
29 | "@repo/typescript-config": "*",
30 | "@tanstack/react-query": "^5.36.2",
31 | "prettier": "^3.2.5",
32 | "prettier-plugin-organize-imports": "^3.2.4",
33 | "turbo": "latest",
34 | "zod": "^3.23.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/packages/eslint-config/README.md:
--------------------------------------------------------------------------------
1 | # `@turbo/eslint-config`
2 |
3 | Collection of internal eslint configurations.
4 |
--------------------------------------------------------------------------------
/packages/eslint-config/library.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require("node:path");
2 |
3 | const project = resolve(process.cwd(), "tsconfig.json");
4 |
5 | /** @type {import("eslint").Linter.Config} */
6 | module.exports = {
7 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"],
8 | plugins: ["only-warn"],
9 | globals: {
10 | React: true,
11 | JSX: true,
12 | },
13 | env: {
14 | node: true,
15 | },
16 | settings: {
17 | "import/resolver": {
18 | typescript: {
19 | project,
20 | },
21 | },
22 | },
23 | ignorePatterns: [
24 | // Ignore dotfiles
25 | ".*.js",
26 | "node_modules/",
27 | "dist/",
28 | ],
29 | overrides: [
30 | {
31 | files: ["*.js?(x)", "*.ts?(x)"],
32 | },
33 | ],
34 | };
35 |
--------------------------------------------------------------------------------
/packages/eslint-config/next.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require("node:path");
2 |
3 | const project = resolve(process.cwd(), "tsconfig.json");
4 |
5 | /** @type {import("eslint").Linter.Config} */
6 | module.exports = {
7 | extends: [
8 | "eslint:recommended",
9 | "prettier",
10 | require.resolve("@vercel/style-guide/eslint/next"),
11 | "eslint-config-turbo",
12 | ],
13 | globals: {
14 | React: true,
15 | JSX: true,
16 | },
17 | env: {
18 | node: true,
19 | browser: true,
20 | },
21 | plugins: ["only-warn"],
22 | settings: {
23 | "import/resolver": {
24 | typescript: {
25 | project,
26 | },
27 | },
28 | },
29 | ignorePatterns: [
30 | // Ignore dotfiles
31 | ".*.js",
32 | "node_modules/",
33 | ],
34 | overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }],
35 | };
36 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/eslint-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "files": [
6 | "library.js",
7 | "next.js",
8 | "react-internal.js"
9 | ],
10 | "devDependencies": {
11 | "@typescript-eslint/eslint-plugin": "^7.1.0",
12 | "@typescript-eslint/parser": "^7.1.0",
13 | "@vercel/style-guide": "^5.2.0",
14 | "eslint-config-prettier": "^9.1.0",
15 | "eslint-config-turbo": "^1.12.4",
16 | "eslint-plugin-only-warn": "^1.1.0",
17 | "typescript": "^5.3.3"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/eslint-config/react-internal.js:
--------------------------------------------------------------------------------
1 | const { resolve } = require("node:path");
2 |
3 | const project = resolve(process.cwd(), "tsconfig.json");
4 |
5 | /*
6 | * This is a custom ESLint configuration for use with
7 | * internal (bundled by their consumer) libraries
8 | * that utilize React.
9 | */
10 |
11 | /** @type {import("eslint").Linter.Config} */
12 | module.exports = {
13 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"],
14 | plugins: ["only-warn"],
15 | globals: {
16 | React: true,
17 | JSX: true,
18 | },
19 | env: {
20 | browser: true,
21 | },
22 | settings: {
23 | "import/resolver": {
24 | typescript: {
25 | project,
26 | },
27 | },
28 | },
29 | ignorePatterns: [
30 | // Ignore dotfiles
31 | ".*.js",
32 | "node_modules/",
33 | "dist/",
34 | ],
35 | overrides: [
36 | // Force ESLint to detect .tsx files
37 | { files: ["*.js?(x)", "*.ts?(x)"] },
38 | ],
39 | };
40 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | module.exports = {
3 | root: true,
4 | extends: ["@repo/eslint-config/next.js"],
5 | parser: "@typescript-eslint/parser",
6 | parserOptions: {
7 | project: true,
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # server-actions-wrapper
2 |
3 | ## 0.1.0
4 |
5 | ### Minor Changes
6 |
7 | - 8217f9a: TypeSafeLink component href change
8 |
9 | ## 0.0.4
10 |
11 | ### Patch Changes
12 |
13 | - 3ec2ab2: Added inferLinkComponentProps
14 |
15 | ## 0.0.3
16 |
17 | ### Patch Changes
18 |
19 | - fdb251d: Fix extractParamsFromPathName return type
20 |
21 | ## 0.0.2
22 |
23 | ### Patch Changes
24 |
25 | - 395ce6e: Fix bug that required params object when there were no params
26 |
27 | ## 0.0.1
28 |
29 | ### Patch Changes
30 |
31 | - a265b04: Initial commit
32 |
33 | ## 0.0.1
34 |
35 | ### Patch Changes
36 |
37 | - 0102ffe: Initial push
38 |
39 | ## 0.0.2
40 |
41 | ### Patch Changes
42 |
43 | - 3ba6b05: Initial commit
44 |
45 | ## 0.1.4
46 |
47 | ### Patch Changes
48 |
49 | - 4a7dbfd: Changing exports
50 |
51 | ## 0.1.3
52 |
53 | ### Patch Changes
54 |
55 | - 3ba216e: Compiler options
56 |
57 | ## 0.1.2
58 |
59 | ### Patch Changes
60 |
61 | - c955c60: Fix type versions
62 |
63 | ## 0.1.1
64 |
65 | ### Patch Changes
66 |
67 | - f84afc3: Added type versions to package json
68 |
69 | ## 0.1.0
70 |
71 | ### Minor Changes
72 |
73 | - eb1296d: Fixed incorrect isLoading states caused by transitions
74 |
75 | ## 0.0.2
76 |
77 | ### Patch Changes
78 |
79 | - 91160f4: prototype
80 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/README.md:
--------------------------------------------------------------------------------
1 | # TypeSafePaths
2 |
3 | TypeSafePaths is a TypeScript library that provides a type-safe way to manage URL paths and their parameters in a web application. It leverages the power of Zod for schema validation, ensuring that both path parameters and search parameters conform to specified schemas.
4 |
5 | ## Features
6 |
7 | - **Type-Safe URL Paths**: Define URL paths with type-safe parameters.
8 | - **Schema Validation**: Use Zod schemas to validate search parameters and metadata.
9 | - **Path Construction**: Easily build URLs from defined paths and parameters.
10 | - **Path Matching**: Match and extract parameters from URLs.
11 | - **Search Parameter Parsing**: Parse and validate search parameters from URLs.
12 |
13 | ## Installation
14 |
15 | ```bash
16 | npm install type-safe-paths
17 | ```
18 |
19 | ## Usage
20 |
21 | ### Defining Paths
22 |
23 | First, create an instance of `TypeSafePaths` and define your paths with the required schemas.
24 |
25 | ```typescript
26 | import z from "zod"
27 | import { TypeSafePaths, createPathHelpers } from "type-safe-paths"
28 |
29 | // Create a new instance of TypeSafePaths with a metadata schema
30 | const paths = new TypeSafePaths({
31 | metadataSchema: z.object({
32 | allowedPermissions: z.array(z.enum(["user", "admin"])),
33 | mustBeLoggedIn: z.boolean(),
34 | }),
35 | })
36 | // Add the first path with metadata and search parameters
37 | .add("/posts/details/:postId", {
38 | metadata: {
39 | allowedPermissions: ["admin"],
40 | mustBeLoggedIn: true,
41 | },
42 | searchParams: z.object({
43 | query: z.string().optional(),
44 | }),
45 | })
46 | // Add a nested path with different metadata and search parameters
47 | .add("/posts/details/:postId/:commentId", {
48 | metadata: {
49 | allowedPermissions: ["user"],
50 | mustBeLoggedIn: false,
51 | },
52 | searchParams: z.object({
53 | query: z.string().default("hello world"),
54 | optional: z.string().default("the parsing worked"),
55 | }),
56 | })
57 | ```
58 |
59 | ### Creating Path Helpers
60 |
61 | Use `createPathHelpers` to generate helper functions for building, matching, and parsing paths.
62 |
63 | ```typescript
64 | const {
65 | buildPath,
66 | matchPath,
67 | parseSearchParamsForPath,
68 | extractParamsFromPathName,
69 | } = createPathHelpers(paths)
70 | ```
71 |
72 | ### Building Paths
73 |
74 | Construct a URL from a defined path and its parameters.
75 |
76 | ```typescript
77 | // Build a URL for the path "/posts/details/:postId/:commentId"
78 | const url = buildPath("/posts/details/:postId/:commentId", {
79 | params: {
80 | postId: "123",
81 | commentId: "456",
82 | },
83 | searchParams: {
84 | query: "example query",
85 | },
86 | })
87 |
88 | console.log(url)
89 | // Output: /posts/details/123/456?query="example+query"
90 | ```
91 |
92 | ### Using buildPath with Next.js
93 |
94 | You can use the `buildPath` function with Next.js component for type-safe routing.
95 |
96 | ```typescript
97 | import Link from 'next/link';
98 | import { buildPath } from 'type-safe-paths';
99 |
100 | const postId = "123";
101 | const commentId = "456";
102 |
103 | const url = buildPath("/posts/details/:postId/:commentId", {
104 | params: { postId, commentId },
105 | searchParams: { query: "example query" },
106 | });
107 |
108 | const MyComponent = () => (
109 |
110 | Go to Comment
111 |
112 | );
113 |
114 | export default MyComponent;
115 | ```
116 |
117 | ### Matching Paths
118 |
119 | Match a URL against the defined paths and extract metadata.
120 |
121 | ```typescript
122 | // Match a URL against the registered paths
123 | const matchResult = matchPath("/posts/details/123/456?query=example+query")
124 |
125 | if (matchResult) {
126 | console.log(matchResult.path)
127 | // Output: /posts/details/:postId/:commentId
128 |
129 | console.log(matchResult.metadata)
130 | // Output: { allowedPermissions: ["user"], testing: "hello world" }
131 | } else {
132 | console.log("No matching path found.")
133 | }
134 | ```
135 |
136 | ### Parsing Search Parameters
137 |
138 | Parse and validate search parameters from a URL.
139 |
140 | ```typescript
141 | // Create a URLSearchParams instance with some query parameters
142 | const searchParams = new URLSearchParams()
143 | searchParams.set("query", "example query")
144 |
145 | // Parse and validate the search parameters for the specified path
146 | const parsedParams = parseSearchParamsForPath(
147 | searchParams,
148 | "/posts/details/:postId/:commentId"
149 | )
150 |
151 | console.log(parsedParams)
152 | // Output: { query: "example query", optional: "the parsing worked" }
153 | ```
154 |
155 | ### Extracting Path Parameters
156 |
157 | Extract path parameters from a URL based on a defined path.
158 |
159 | ```typescript
160 | // Extract path parameters from a URL based on the specified path template
161 | const params = extractParamsFromPathName(
162 | "/posts/details/123/456?query=example+query",
163 | "/posts/details/:postId/:commentId"
164 | )
165 |
166 | console.log(params)
167 | // Output: { postId: "123", commentId: "456" }
168 | ```
169 |
170 | ### Inferring Types
171 |
172 | Use `inferPathProps` to infer the types of path parameters and search parameters for a given path. This ensures that your components receive the correct types, enhancing type safety and reducing errors.
173 |
174 | ```typescript
175 | import { inferPathProps } from "type-safe-paths";
176 |
177 | // Infer types for the path "/posts/details/:postId/:commentId"
178 | type PathProps = inferPathProps;
179 |
180 | // Example component using inferred types
181 | export default async function MyPage({ params, searchParams }: PathProps) {
182 | // params and searchParams will have the correct types based on the defined schema
183 | console.log(params.postId); // string
184 | console.log(params.commentId); // string
185 | console.log(searchParams.query); // string
186 | console.log(searchParams.optional); // string
187 |
188 | // Your component logic here
189 | ...
190 | }
191 | ```
192 |
193 | This approach ensures that `params` and `searchParams` in your component have the correct types as specified in the path definition, making your code more robust and maintainable.
194 |
195 | ### Type-Safe Link Component
196 |
197 | You can create a type-safe link component using the `inferLinkComponentProps` utility function. This ensures that the link component receives the correct props based on the defined paths.
198 |
199 | ```typescript
200 | import Link from 'next/link'
201 | import { TypeSafePaths, createPathHelpers, inferLinkComponentProps } from 'type-safe-paths'
202 | import { forwardRef } from 'react'
203 |
204 | // Create a new instance of TypeSafePaths
205 | const paths = new TypeSafePaths({
206 | // Your path definitions here
207 | ...
208 | })
209 |
210 | // Create a type-safe link component
211 | const TypeSafeLink = forwardRef<
212 | HTMLAnchorElement,
213 | inferLinkComponentProps
214 | >((props, ref) => {
215 | const { getHrefFromLinkComponentProps } = createPathHelpers(paths)
216 | return (
217 |
218 | )
219 | })
220 | ```
221 |
222 | In this example, we create a `TypeSafeLink` component using `forwardRef`. The component receives props of type `inferLinkComponentProps`, which infers the correct props based on the defined paths in `myPaths`.
223 |
224 | Inside the component, we use the `getHrefFromLinkComponentProps` function from `createPathHelpers` to generate the `href` prop for the `Link` component based on the provided props.
225 |
226 | Now you can use the `TypeSafeLink` component in your application, and it will ensure that the props you pass to it match the defined paths and their parameters.
227 |
228 | ```typescript
229 |
236 | Go to Comment
237 |
238 | ```
239 |
240 | The `TypeSafeLink` component will provide type safety and autocomplete suggestions for the `path` and `params` props based on your defined paths.
241 |
242 | ## API
243 |
244 | ### `TypeSafePaths`
245 |
246 | #### `constructor(args?: { metadataSchema?: TMetadataSchema })`
247 |
248 | Creates an instance of `TypeSafePaths` with optional metadata schema.
249 |
250 | #### `add(path: TPath, opts: { searchParams?: TSearchParams, metadata: z.input })`
251 |
252 | Adds a path to the registry with optional search parameters and metadata.
253 |
254 | ### `createPathHelpers(registry: TypeSafePaths)`
255 |
256 | #### `buildPath(k: TKey, opts: { params: { [k in TRegistry["$registry"][TKey]["params"]]: string }, searchParams?: z.input })`
257 |
258 | Builds a URL from a defined path and its parameters.
259 |
260 | #### `matchPath(pathname: string)`
261 |
262 | Matches a URL against the defined paths and extracts metadata.
263 |
264 | #### `extractParamsFromPathName(pathname: string, path: TPath)`
265 |
266 | Extracts path parameters from a URL.
267 |
268 | #### `parseSearchParamsForPath(searchParams: URLSearchParams, path: TPath)`
269 |
270 | Parses and validates search parameters from a URL.
271 |
272 | ### `inferPathProps`
273 |
274 | #### `inferPathProps, TPath extends keyof TRegistry["$registry"]>`
275 |
276 | Infers the types of path parameters and search parameters for a given path.
277 |
278 | ### `inferLinkComponentProps`
279 |
280 | #### `inferLinkComponentProps, TLinkComponent extends React.ComponentType>`
281 |
282 | Infers the props for a type-safe link component based on the defined paths and the underlying link component.
283 |
284 | ## License
285 |
286 | MIT
287 |
288 | ## Contributing
289 |
290 | Contributions are welcome! Please open an issue or submit a pull request on GitHub.
291 |
292 | ## Acknowledgments
293 |
294 | This library was inspired by the need for a type-safe way to manage URL paths and parameters in modern web applications, leveraging the power of Zod for schema validation.
295 |
296 | ---
297 |
298 | Feel free to reach out if you have any questions or need further assistance!
299 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "type-safe-paths",
3 | "version": "0.1.0",
4 | "publishConfig": {
5 | "access": "public"
6 | },
7 | "files": [
8 | "dist/**"
9 | ],
10 | "exports": {
11 | ".": "./dist/index.mjs"
12 | },
13 | "typesVersions": {
14 | "*": {
15 | ".": [
16 | "./dist/index.d.mts"
17 | ]
18 | }
19 | },
20 | "sideEffects": false,
21 | "license": "MIT",
22 | "main": "./dist/index.js",
23 | "module": "./dist/index.mjs",
24 | "types": "./dist/index.d.ts",
25 | "scripts": {
26 | "lint": "eslint .",
27 | "lint:fix": "eslint .",
28 | "typecheck": "tsc --noEmit",
29 | "format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
30 | "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
31 | "build": "tsup src/index.ts --format cjs,esm --dts",
32 | "dev": "npm run build -- --watch",
33 | "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
34 | "test": "npx tsx src/tests.ts"
35 | },
36 | "dependencies": {
37 | "path-to-regexp": "^6.2.2",
38 | "typescript": "latest",
39 | "zod": "^3.23.5"
40 | },
41 | "devDependencies": {
42 | "tsup": "^8.0.2"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/src/index.ts:
--------------------------------------------------------------------------------
1 | import { pathToRegexp } from "path-to-regexp"
2 | import z from "zod"
3 |
4 | type Length = T extends { length: infer L } ? L : never
5 |
6 | type IsEmptyObject = keyof T extends never ? true : false
7 |
8 | type BuildTuple = T extends {
9 | length: L
10 | }
11 | ? T
12 | : BuildTuple
13 |
14 | type Add = Length<
15 | [...BuildTuple, ...BuildTuple]
16 | >
17 |
18 | type PrettifyNested = {
19 | [K in keyof T]: T[K] extends Object ? PrettifyNested : T[K]
20 | } & {}
21 |
22 | type Banned =
23 | | `${string}${"." | " " | "/" | "#" | "&" | "?" | "|" | ":"}${string}`
24 | | ``
25 | type Segment = T extends Banned ? never : T
26 |
27 | type MAX_DEPTH = 5
28 | type Path<
29 | TSegmentValue extends string,
30 | TDepth extends number = 0,
31 | > = TDepth extends MAX_DEPTH
32 | ? never
33 | :
34 | | (TDepth extends 0 ? `/` : ``)
35 | | "(.*)"
36 | | `/${":" | ""}${Segment}${Path : never>}`
37 |
38 | type TExtractPathFromString =
39 | T extends Path ? Path : never
40 |
41 | type TExtractSegmentFromPath> =
42 | T extends Path ? TSegmentValue : never
43 |
44 | type TExtractParamsFromSegment = T extends `:${infer P}`
45 | ? P extends Banned
46 | ? never
47 | : P
48 | : never
49 |
50 | interface TPathRegistryNode<
51 | TKeys extends string,
52 | TSearchParams extends z.AnyZodObject | undefined,
53 | > {
54 | params: TKeys
55 | searchParams: TSearchParams
56 | }
57 |
58 | type TPathsRegistryObject = {
59 | [key: string]: TPathRegistryNode
60 | }
61 |
62 | export class TypeSafePaths<
63 | TPathsRegistry extends TPathsRegistryObject = {},
64 | TMetadataSchema extends z.ZodType | undefined = undefined,
65 | > {
66 | $metadataSchema: TMetadataSchema | undefined
67 | $registry: TPathsRegistry
68 | $dataMap: Record<
69 | keyof TPathsRegistry,
70 | {
71 | searchParamsSchema: z.ZodType
72 | metadata: TMetadataSchema extends z.ZodType
73 | ? z.input
74 | : undefined
75 | }
76 | >
77 |
78 | constructor(args?: { metadataSchema?: TMetadataSchema }) {
79 | this.$metadataSchema = args?.metadataSchema
80 |
81 | this.$registry = 5 as any
82 | this.$dataMap = {} as any
83 | }
84 |
85 | add<
86 | const TPath extends string,
87 | TSearchParams extends z.AnyZodObject | undefined,
88 | >(
89 | path: TExtractPathFromString extends never
90 | ? never
91 | : TPath extends keyof TPathsRegistry
92 | ? never
93 | : TPath,
94 | opts: {
95 | searchParams?: TSearchParams
96 | } & (TMetadataSchema extends z.ZodType
97 | ? {
98 | metadata: z.input
99 | }
100 | : {})
101 | ): TypeSafePaths<
102 | TPathsRegistry & {
103 | [k in TPath]: TPath extends Path
104 | ? TPathRegistryNode<
105 | TExtractParamsFromSegment>,
106 | TSearchParams
107 | >
108 | : never
109 | },
110 | TMetadataSchema
111 | > {
112 | // @ts-expect-error
113 | this.$dataMap[path] = {
114 | searchParamsSchema: opts.searchParams,
115 | metadata: "metadata" in opts ? opts.metadata : undefined,
116 | }
117 |
118 | return this as any
119 | }
120 | }
121 |
122 | export const createPathHelpers = <
123 | const TRegistry extends TypeSafePaths,
124 | >(
125 | registry: TRegistry
126 | ) => {
127 | type TSearchParams =
128 | TRegistry["$registry"][TKey]["searchParams"]
129 |
130 | type TParams =
131 | TRegistry["$registry"][TKey]["params"]
132 |
133 | type TOpts =
134 | (TParams extends never
135 | ? {}
136 | : {
137 | params: {
138 | [k in TRegistry["$registry"][TKey]["params"]]: string
139 | }
140 | }) &
141 | (TSearchParams extends z.AnyZodObject
142 | ? {
143 | searchParams: z.input>
144 | }
145 | : {}) & {}
146 |
147 | const buildPath = (
148 | k: TKey,
149 | ...optsArr: IsEmptyObject> extends true ? [] : [TOpts]
150 | ): string => {
151 | let mutatedPath = k.toString()
152 |
153 | const opts = optsArr[0]
154 |
155 | // replace params in path
156 | if (opts && "params" in opts && opts.params) {
157 | for (const [key, value] of Object.entries(opts.params)) {
158 | mutatedPath = mutatedPath.replace(`:${key}`, value as string)
159 | }
160 | }
161 |
162 | const dummyBase = "http://localhost"
163 |
164 | // remove regex from path
165 | if (mutatedPath.includes("(.*)")) {
166 | mutatedPath = mutatedPath.replace("(.*)", "")
167 | }
168 |
169 | const url = new URL(mutatedPath, dummyBase)
170 |
171 | if (opts && !("searchParams" in opts)) {
172 | return url.pathname
173 | }
174 |
175 | let parsed = opts && "searchParams" in opts ? opts.searchParams : undefined
176 |
177 | const schema = registry.$dataMap[k].searchParamsSchema || undefined
178 | if (schema) {
179 | parsed = schema.parse(parsed)
180 | }
181 |
182 | if (!parsed) return url.pathname
183 |
184 | // add search params
185 | for (const [key, value] of Object.entries(parsed)) {
186 | url.searchParams.set(key, JSON.stringify(value))
187 | }
188 |
189 | return url.pathname + url.search
190 | }
191 |
192 | const getHrefFromLinkComponentProps = (props: {
193 | href: { pathname: string; params?: any; searchParams?: any }
194 | }): string => {
195 | // @ts-expect-error
196 | return buildPath(props.href.pathname, {
197 | params: props.href.params,
198 | searchParams: props.href.searchParams,
199 | })
200 | }
201 |
202 | const matchPath = (
203 | pathname: string
204 | ):
205 | | {
206 | path: keyof TRegistry["$registry"]
207 | metadata: TRegistry extends TypeSafePaths
208 | ? TMetadataSchema extends z.ZodType
209 | ? z.output
210 | : undefined
211 | : undefined
212 | }
213 | | undefined => {
214 | const found = Object.keys(registry.$dataMap).find((path) => {
215 | const re = pathToRegexp(path)
216 | const match = re.exec(pathname.split("?")[0] || "NEVER_MATCH")
217 |
218 | if (!match) {
219 | return false
220 | }
221 |
222 | return true
223 | })
224 |
225 | if (!found) return undefined
226 |
227 | return {
228 | path: found as keyof TRegistry["$registry"],
229 | metadata: registry.$metadataSchema
230 | ? registry.$metadataSchema.parse(
231 | registry.$dataMap[found]?.metadata || {}
232 | )
233 | : undefined,
234 | }
235 | }
236 |
237 | const extractParamsFromPathName = <
238 | TPath extends keyof TRegistry["$registry"],
239 | >(
240 | pathname: string,
241 | path: TPath
242 | ): {
243 | [k in TRegistry["$registry"][TPath]["params"]]: string
244 | } => {
245 | const finalParams: Record = {}
246 |
247 | let basePathSplit = (path as string).split("/")
248 | let pathSplit = (pathname.split("?")[0] || "NEVER_MATCH").split("/")
249 |
250 | if (basePathSplit.length !== pathSplit.length) {
251 | return {} as any
252 | }
253 |
254 | // copy over the params
255 | for (let i = 0; i < basePathSplit.length; i++) {
256 | const basePathPart = basePathSplit[i]
257 | const pathPart = pathSplit[i]
258 |
259 | if (!basePathPart || !pathPart) {
260 | continue
261 | }
262 |
263 | if (basePathPart.startsWith(":")) {
264 | const foundPathPartName = basePathPart.slice(1)
265 | finalParams[foundPathPartName] = pathPart
266 | }
267 | }
268 |
269 | return finalParams
270 | }
271 |
272 | const parseSearchParamsForPath = (
273 | searchParams: URLSearchParams,
274 | path: TPath
275 | ): TRegistry["$registry"][TPath]["searchParams"] extends z.AnyZodObject
276 | ? z.output
277 | : undefined => {
278 | const result: any = {}
279 |
280 | searchParams.forEach((value, key) => {
281 | const k = key
282 | try {
283 | result[k] = JSON.parse(value)
284 | } catch (e) {
285 | result[k] = value
286 | }
287 | })
288 |
289 | const schema = registry.$dataMap[path].searchParamsSchema || undefined
290 | if (schema) {
291 | return schema.parse(result)
292 | }
293 |
294 | return result
295 | }
296 |
297 | return {
298 | /**
299 | * Builds a path from a path with params and search params
300 | */
301 | buildPath,
302 |
303 | /**
304 | * Matches a pathname against the paths in the registry
305 | */
306 | matchPath,
307 |
308 | /**
309 | * Extracts the params from a pathname for a given path
310 | */
311 | extractParamsFromPathName,
312 |
313 | /**
314 | * Parses the search params for a path
315 | */
316 | parseSearchParamsForPath,
317 |
318 | /**
319 | * Parse the href from a link component props
320 | */
321 | getHrefFromLinkComponentProps,
322 | }
323 | }
324 |
325 | export type inferPathProps<
326 | TRegistry extends TypeSafePaths,
327 | TPath extends keyof TRegistry["$registry"],
328 | > = PrettifyNested<{
329 | params: {
330 | [K in TRegistry["$registry"][TPath]["params"]]: string
331 | }
332 | searchParams: TRegistry["$registry"][TPath]["searchParams"] extends z.AnyZodObject
333 | ? z.input
334 | : undefined
335 | }>
336 |
337 | export type inferLinkComponentProps<
338 | TRegistry extends TypeSafePaths,
339 | TLinkComponent extends (...args: any) => any = (...args: any) => any,
340 | > = (Parameters[0] extends Object
341 | ? Parameters[0]
342 | : {}) & {
343 | href: {
344 | [TPath in keyof TRegistry["$registry"]]: PrettifyNested<
345 | {
346 | pathname: TPath
347 | } & (keyof inferPathProps["params"] extends never
348 | ? {}
349 | : {
350 | params: inferPathProps["params"]
351 | }) &
352 | (inferPathProps["searchParams"] extends undefined
353 | ? {}
354 | : {
355 | searchParams: inferPathProps["searchParams"]
356 | })
357 | >
358 | }[keyof TRegistry["$registry"]]
359 | }
360 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/src/tests.ts:
--------------------------------------------------------------------------------
1 | import z from "zod"
2 | import { TypeSafePaths, createPathHelpers, inferPathProps } from "."
3 |
4 | const paths = new TypeSafePaths({
5 | metadataSchema: z.object({
6 | allowedPermissions: z.array(z.enum(["user", "admin"])),
7 | testing: z.string(),
8 | }),
9 | })
10 | .add("/posts/details/:postId", {
11 | metadata: {
12 | allowedPermissions: ["admin"],
13 | testing: "hello world",
14 | },
15 | searchParams: z.object({
16 | query: z.string().optional(),
17 | }),
18 | })
19 | .add("/posts/details/:postId/:commentId", {
20 | metadata: {
21 | allowedPermissions: ["user"],
22 | testing: "hello world",
23 | },
24 | searchParams: z.object({
25 | query: z.string().default("hello world"),
26 | optional: z.string().default(" the parsing worked"),
27 | }),
28 | })
29 | .add("/posts", {
30 | metadata: {
31 | allowedPermissions: ["user"],
32 | testing: "hello world",
33 | },
34 | searchParams: z.object({
35 | query: z.string().optional(),
36 | }),
37 | })
38 | .add("/api(.*)", {
39 | metadata: {
40 | allowedPermissions: ["user"],
41 | testing: "hello world",
42 | },
43 | })
44 |
45 | const {
46 | buildPath,
47 | matchPath,
48 | parseSearchParamsForPath,
49 | extractParamsFromPathName,
50 | } = createPathHelpers(paths)
51 |
52 | type Test = inferPathProps
53 |
54 | const testPath = buildPath("/posts/details/:postId/:commentId", {
55 | params: {
56 | postId: "123",
57 | commentId: "456",
58 | },
59 | searchParams: {},
60 | })
61 |
62 | const match = matchPath("/posts/details/123/456?query=%22hello+world%22")
63 |
64 | const testParams = new URLSearchParams()
65 | testParams.set("query", '"hello world"')
66 |
67 | const final = parseSearchParamsForPath(
68 | testParams,
69 | "/posts/details/:postId/:commentId"
70 | )
71 |
72 | console.log(testPath)
73 | console.log(match)
74 | console.log("final", final)
75 | const result = extractParamsFromPathName(
76 | "/posts/details/123/456?query=%22hello+world%22",
77 | "/posts"
78 | )
79 |
80 | console.log(buildPath("/api(.*)"))
81 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["."],
4 | "exclude": ["dist", "build", "node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/type-safe-paths/tsconfig.lint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/react-library.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src", "turbo"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "declaration": true,
6 | "declarationMap": true,
7 | "esModuleInterop": true,
8 | "incremental": false,
9 | "isolatedModules": true,
10 | "lib": ["es2022", "DOM", "DOM.Iterable"],
11 | "module": "NodeNext",
12 | "moduleDetection": "force",
13 | "moduleResolution": "NodeNext",
14 | "noUncheckedIndexedAccess": true,
15 | "resolveJsonModule": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "ES2022"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/typescript-config/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Next.js",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "plugins": [{ "name": "next" }],
7 | "module": "ESNext",
8 | "moduleResolution": "Bundler",
9 | "allowJs": true,
10 | "jsx": "preserve",
11 | "noEmit": true
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/typescript-config/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | endOfLine: "lf",
4 | semi: false,
5 | singleQuote: false,
6 | tabWidth: 2,
7 | trailingComma: "es5",
8 | importOrder: [
9 | "^(react/(.*)$)|^(react$)",
10 | "^(next/(.*)$)|^(next$)",
11 | "",
12 | "",
13 | "^types$",
14 | "^@/types/(.*)$",
15 | "^@/config/(.*)$",
16 | "^@/lib/(.*)$",
17 | "^@/hooks/(.*)$",
18 | "^@/components/ui/(.*)$",
19 | "^@/components/(.*)$",
20 | "^@/registry/(.*)$",
21 | "^@/styles/(.*)$",
22 | "^@/app/(.*)$",
23 | "",
24 | "^[./]",
25 | ],
26 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
27 | plugins: [
28 | "@ianvs/prettier-plugin-sort-imports",
29 | "prettier-plugin-organize-imports"
30 | ],
31 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json"
3 | }
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["**/.env.*local"],
4 | "pipeline": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": [".next/**", "!.next/cache/**", "dist/**"]
8 | },
9 | "dev": {
10 | "cache": false,
11 | "persistent": true
12 | },
13 | "typecheck": {},
14 | "clean": {
15 | "cache": false
16 | },
17 | "start": {
18 | "dependsOn": ["^build"]
19 | },
20 | "lint": {
21 | "cache": false,
22 | "outputs": []
23 | },
24 | "lint:fix": {
25 | "cache": false,
26 | "outputs": []
27 | },
28 | "format:check": {
29 | "cache": false,
30 | "outputs": []
31 | },
32 | "format:write": {
33 | "cache": false,
34 | "outputs": []
35 | },
36 | "check": {
37 | "cache": false
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------