├── .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 | --------------------------------------------------------------------------------