]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
feat(types): infer params types from path
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 2 May 2022 16:45:58 +0000 (18:45 +0200)
committerEduardo San Martin Morote <posva@users.noreply.github.com>
Thu, 30 Jun 2022 07:59:00 +0000 (09:59 +0200)
src/index.ts
src/types/paths.ts [new file with mode: 0644]
test-dts/paths.test-d.ts [new file with mode: 0644]

index d3d2851df8d5f0026493ab5fc7f5368d7e7c9ff7..7c583a216317289442abaa919cc9aa673b007340 100644 (file)
@@ -59,6 +59,12 @@ export type {
   NavigationGuardWithThis,
   NavigationHookAfter,
 } from './types'
+export type {
+  ParamsFromPath,
+  _ExtractFirstParamName,
+  _RemoveRegexpFromParam,
+  _RemoveUntilClosingPar,
+} from './types/paths'
 
 export { createRouter } from './router'
 export type { Router, RouterOptions, RouterScrollBehavior } from './router'
diff --git a/src/types/paths.ts b/src/types/paths.ts
new file mode 100644 (file)
index 0000000..72d965f
--- /dev/null
@@ -0,0 +1,277 @@
+/**
+ * Generic possible params from a path (after parsing).
+ */
+export type PathParams = Record<
+  string,
+  string | readonly string[] | undefined | null
+>
+
+/**
+ * Possible param modifiers.
+ *
+ * @internal
+ */
+export type _ParamModifier = '+' | '?' | '*'
+
+/**
+ * Characters that mark the end of a param. In reality, there is a lot more than
+ * this as only alphanumerical + _ are accepted as params but that is impossible
+ * to achieve with TS and in practice, This set should cover them all. TODO: Add
+ * missing characters that do not need to be encoded.
+ *
+ * @internal
+ */
+type _ParamDelimiter =
+  | '-'
+  | '/'
+  | '%'
+  | ':'
+  | '('
+  | '\\'
+  | ';'
+  | ','
+  | '&'
+  | '!'
+  | "'"
+  | '='
+  | '@'
+  | '['
+  | ']'
+  | _ParamModifier
+
+/**
+ * Given a simple path, creates an object of the possible param values.
+ *
+ * @internal
+ */
+export type _ExtractParamsPath<P extends string> =
+  P extends `${string}{${infer PP}}${infer Rest}`
+    ? (PP extends `${infer N}${_ParamModifier}`
+        ? PP extends `${N}${infer M}`
+          ? M extends _ParamModifier
+            ? _ParamToObject<N, M>
+            : never
+          : never
+        : _ParamToObject<PP, ''>) &
+        _ExtractParamsPath<Rest>
+    : {}
+
+/**
+ * Extract an object of params given a path like `/users/:id`.
+ */
+export type ParamsFromPath<P extends string = string> = string extends P
+  ? PathParams // Generic version
+  : _ExtractParamsPath<_RemoveRegexpFromParam<P>>
+
+/**
+ * Gets the possible type of a param based on its modifier M.
+ *
+ * @internal
+ */
+export type _ModifierParamValue<
+  M extends _ParamModifier | '' = _ParamModifier | ''
+> = '' extends M
+  ? string
+  : '+' extends M
+  ? readonly [string, ...string[]]
+  : '*' extends M
+  ? readonly string[] | undefined | null
+  : '?' extends M
+  ? string | undefined | null
+  : never
+
+/**
+ * Given a param name N and its modifier M, creates a param object for the pair.
+ *
+ * @internal
+ */
+export type _ParamToObject<
+  N extends string,
+  M extends _ParamModifier | ''
+> = M extends '?' | '*'
+  ? {
+      [K in N]?: _ModifierParamValue<M>
+    }
+  : {
+      [K in N]: _ModifierParamValue<M>
+    }
+
+/**
+ * Takes the custom regex (and everything after) of a param and strips it off.
+ *
+ * @example
+ * - `\\d+(?:inner-group\\)-end)/:rest-of-url` -> `/:rest-of-url`
+ *
+ * @internal
+ */
+export type _RemoveUntilClosingPar<S extends string> =
+  S extends `${infer A}\\)${infer Rest}`
+    ? A extends `${string})${infer Rest2}` // the actual regexp finished before, AA has no escaped )
+      ? Rest2 extends `${_ParamModifier}${infer Rest3}`
+        ? Rest2 extends `${infer M}${Rest3}`
+          ? `${M}}${Rest3}\\)${Rest}`
+          : never
+        : `}${Rest2}\\)${Rest}` // job done
+      : _RemoveUntilClosingPar<Rest> // we keep removing
+    : S extends `${string})${infer Rest}`
+    ? Rest extends `${_ParamModifier}${infer Rest2}`
+      ? Rest extends `${infer M}${Rest2}`
+        ? `${M}}${Rest2}`
+        : never
+      : `}${Rest}`
+    : never // nothing to remove, should not have been called, easier to spot bugs
+
+/**
+ * Reformats a path string `/:id(custom-regex)/:other+` by wrapping params with
+ * `{}` and removing custom regexps to make them easier to parse.
+ *
+ * @internal
+ */
+export type _RemoveRegexpFromParam<S extends string> =
+  S extends `${infer A}:${infer P}${_ParamDelimiter}${infer Rest}`
+    ? P extends _ExtractFirstParamName<P>
+      ? S extends `${A}:${P}${infer D}${Rest}`
+        ? D extends _ParamModifier | ''
+          ? `${A}{${P}${D}}${S extends `${A}:${P}${D}${infer Rest2}` // we need to infer again...
+              ? _RemoveRegexpFromParam<Rest2>
+              : never}`
+          : D extends _ParamDelimiter
+          ? '(' extends D
+            ? `${A}{${P}${S extends `${A}:${P}(${infer Rest2}` // we need to infer again to include D
+                ? _RemoveRegexpFromParam<_RemoveUntilClosingPar<Rest2>>
+                : '}'}`
+            : `${A}{${P}}${S extends `${A}:${P}${infer Rest2}` // we need to infer again to include D
+                ? _RemoveRegexpFromParam<Rest2>
+                : never}`
+          : never
+        : never
+      : never
+    : S extends `${infer A}:${infer P}`
+    ? P extends _ExtractFirstParamName<P>
+      ? `${A}{${P}}`
+      : never
+    : S
+
+/**
+ * Extract the first param name (after a `:`) and ignores the rest.
+ *
+ * @internal
+ */
+export type _ExtractFirstParamName<S extends string> =
+  S extends `${infer P}${_ParamDelimiter}${string}`
+    ? _ExtractFirstParamName<P>
+    : S extends `${string}${_ParamDelimiter}${string}`
+    ? never
+    : S
+
+/**
+ * Join an array of param values
+ *
+ * @internal
+ */
+type _JoinParams<V extends null | undefined | readonly any[]> = V extends
+  | null
+  | undefined
+  ? ''
+  : V extends readonly [infer A, ...infer Rest]
+  ? A extends string
+    ? `${A}${Rest extends readonly [any, ...any[]]
+        ? `/${_JoinParams<Rest>}`
+        : ''}`
+    : never
+  : ''
+
+/**
+ * Transform a param value to a string.
+ *
+ * @internal
+ */
+export type _ParamToString<V> = V extends null | undefined | readonly string[]
+  ? _JoinParams<V>
+  : V extends null | undefined | readonly never[] | readonly []
+  ? ''
+  : V extends string
+  ? V
+  : `oops`
+
+/**
+ * Possible values for a Modifier.
+ *
+ * @internal
+ */
+type _PossibleModifierValue =
+  | string
+  | readonly string[]
+  | null
+  | undefined
+  | readonly never[]
+
+/**
+ * Recursively builds a path from a {param} based path
+ *
+ * @internal
+ */
+export type _BuildPath<
+  P extends string,
+  PO extends ParamsFromPath
+> = P extends `${infer A}{${infer PP}}${infer Rest}`
+  ? PP extends `${infer N}${_ParamModifier}`
+    ? PO extends Record<N, _PossibleModifierValue>
+      ? PO[N] extends readonly [] | readonly never[] | null | undefined
+        ? `${A}${Rest extends `/${infer Rest2}` ? _BuildPath<Rest2, PO> : ''}`
+        : `${A}${_ParamToString<PO[N]>}${_BuildPath<Rest, PO>}`
+      : `${A}${Rest extends `/${infer Rest2}` ? _BuildPath<Rest2, PO> : ''}`
+    : `${A}${PO extends Record<PP, _PossibleModifierValue>
+        ? _ParamToString<PO[PP]>
+        : ''}${_BuildPath<Rest, PO>}`
+  : P
+
+/**
+ * Builds a path string type from a path definition and an object of params.
+ * @example
+ * ```ts
+ * type url = PathFromParams<'/users/:id', { id: 'posva' }> -> '/users/posva'
+ * ```
+ */
+export type PathFromParams<
+  P extends string,
+  PO extends ParamsFromPath<P>
+> = string extends P ? string : _BuildPath<_RemoveRegexpFromParam<P>, PO>
+
+/**
+ * A param in a url like `/users/:id`.
+ */
+export interface PathParserParamKey<
+  N extends string = string,
+  M extends _ParamModifier | '' = _ParamModifier | ''
+> {
+  name: N
+  repeatable: M extends '+' | '*' ? true : false
+  optional: M extends '?' | '*' ? true : false
+}
+
+/**
+ * Extracts the params of a path.
+ *
+ * @internal
+ */
+export type _ExtractPathParamKeys<P extends string> =
+  P extends `${string}{${infer PP}}${infer Rest}`
+    ? [
+        PP extends `${infer N}${_ParamModifier}`
+          ? PP extends `${N}${infer M}`
+            ? M extends _ParamModifier
+              ? PathParserParamKey<N, M>
+              : never
+            : never
+          : PathParserParamKey<PP, ''>,
+        ..._ExtractPathParamKeys<Rest>
+      ]
+    : []
+
+/**
+ * Extract the param keys (name and modifiers) tuple of a path.
+ */
+export type ParamKeysFromPath<P extends string = string> = string extends P
+  ? readonly PathParserParamKey[] // Generic version
+  : _ExtractPathParamKeys<_RemoveRegexpFromParam<P>>
diff --git a/test-dts/paths.test-d.ts b/test-dts/paths.test-d.ts
new file mode 100644 (file)
index 0000000..0342cec
--- /dev/null
@@ -0,0 +1,132 @@
+import type {
+  ParamsFromPath,
+  _ExtractFirstParamName,
+  _RemoveRegexpFromParam,
+  _RemoveUntilClosingPar,
+} from './'
+import { expectType } from './'
+
+function params<T extends string>(_path: T): ParamsFromPath<T> {
+  return {} as any
+}
+
+// simple
+expectType<{}>(params('/static'))
+expectType<{ id: string }>(params('/users/:id'))
+// simulate a part of the string unknown at compilation time
+expectType<{ id: string }>(params(`/${encodeURI('')}/:id`))
+expectType<{ id: readonly [string, ...string[]] }>(params('/users/:id+'))
+expectType<{ id?: string | null | undefined }>(params('/users/:id?'))
+expectType<{ id?: readonly string[] | null | undefined }>(params('/users/:id*'))
+
+// @ts-expect-error
+expectType<{ other: string }>(params('/users/:id'))
+// @ts-expect-error
+expectType<{ other: string }>(params('/users/static'))
+
+// at beginning
+expectType<{ id: string }>(params('/:id'))
+expectType<{ id: readonly [string, ...string[]] }>(params('/:id+'))
+expectType<{ id?: string | null | undefined }>(params('/:id?'))
+expectType<{ id?: readonly string[] | null | undefined }>(params('/:id*'))
+
+// with trailing path
+expectType<{ id: string }>(params('/users/:id-more'))
+expectType<{ id: readonly [string, ...string[]] }>(params('/users/:id+-more'))
+expectType<{ id?: string | null | undefined }>(params('/users/:id?-more'))
+expectType<{ id?: readonly string[] | null | undefined }>(
+  params('/users/:id*-more')
+)
+
+// multiple
+expectType<{ id: string; b: string }>(params('/users/:id/:b'))
+expectType<{
+  id: readonly [string, ...string[]]
+  b: readonly [string, ...string[]]
+}>(params('/users/:id+/:b+'))
+expectType<{ id?: string | null | undefined; b?: string | null | undefined }>(
+  params('/users/:id?-:b?')
+)
+expectType<{
+  id?: readonly string[] | null | undefined
+  b?: readonly string[] | null | undefined
+}>(params('/users/:id*/:b*'))
+
+// custom regex
+expectType<{ id: string }>(params('/users/:id(one)'))
+expectType<{ id: string }>(params('/users/:id(\\d+)'))
+expectType<{ id: readonly string[] }>(params('/users/:id(one)+'))
+expectType<{ date: string }>(params('/users/:date(\\d{4}-\\d{2}-\\d{2})'))
+expectType<{ a: string }>(params('/:a(pre-(?:\\d{0,5}\\)-end)'))
+
+// special characters
+expectType<{ id$thing: string }>(params('/:id$thing'))
+expectType<{ id: string }>(params('/:id&thing'))
+expectType<{ id: string }>(params('/:id!thing'))
+expectType<{ id: string }>(params('/:id\\*thing'))
+expectType<{ id: string }>(params('/:id\\thing'))
+expectType<{ id: string }>(params("/:id'thing"))
+expectType<{ id: string }>(params('/:id,thing'))
+expectType<{ id: string }>(params('/:id;thing'))
+expectType<{ id: string }>(params('/:id=thing'))
+expectType<{ id: string }>(params('/:id@thing'))
+expectType<{ id: string }>(params('/:id[thing'))
+expectType<{ id: string }>(params('/:id]thing'))
+
+function removeUntilClosingPar<S extends string>(
+  _s: S
+): _RemoveUntilClosingPar<S> {
+  return '' as any
+}
+
+expectType<'}'>(removeUntilClosingPar(')'))
+expectType<'+}'>(removeUntilClosingPar(')+'))
+expectType<'}more'>(removeUntilClosingPar(')more'))
+expectType<'}'>(removeUntilClosingPar('\\w+)'))
+expectType<'}/more-url'>(removeUntilClosingPar('\\w+)/more-url'))
+expectType<'}/:p'>(removeUntilClosingPar('\\w+)/:p'))
+expectType<'+}'>(removeUntilClosingPar('oe)+'))
+expectType<'}/:p(o)'>(removeUntilClosingPar('\\w+)/:p(o)'))
+expectType<'}/:p(o)'>(removeUntilClosingPar('(?:no\\)?-end)/:p(o)'))
+expectType<'}/:p(o(?:no\\)?-end)'>(
+  removeUntilClosingPar('-end)/:p(o(?:no\\)?-end)')
+)
+expectType<'}:new(eg)other'>(removeUntilClosingPar('customr):new(eg)other'))
+expectType<'}:new(eg)+other'>(removeUntilClosingPar('customr):new(eg)+other'))
+expectType<'}/:new(eg)+other'>(removeUntilClosingPar('customr)/:new(eg)+other'))
+expectType<'?}/:new(eg)+other'>(
+  removeUntilClosingPar('customr)?/:new(eg)+other')
+)
+function removeRegexp<S extends string>(_s: S): _RemoveRegexpFromParam<S> {
+  return '' as any
+}
+
+expectType<'/{id?}/{b}'>(removeRegexp('/:id(aue(ee{2,3}\\))?/:b(hey)'))
+expectType<'/{id+}/b'>(removeRegexp('/:id+/b'))
+expectType<'/{id}'>(removeRegexp('/:id'))
+expectType<'/{id+}'>(removeRegexp('/:id+'))
+expectType<'+}'>(removeRegexp('+}'))
+expectType<'/{id+}'>(removeRegexp('/:id(e)+'))
+expectType<'/{id}/b'>(removeRegexp('/:id/b'))
+expectType<'/{id}/{b}'>(removeRegexp('/:id/:b'))
+expectType<'/users/{id}/{b}'>(removeRegexp('/users/:id/:b'))
+expectType<'/{id?}/{b+}'>(removeRegexp('/:id?/:b+'))
+expectType<'/{id?}/{b+}'>(removeRegexp('/:id(aue(ee{2,3}\\))?/:b+'))
+
+function extractParamName<S extends string>(_s: S): _ExtractFirstParamName<S> {
+  return '' as any
+}
+
+expectType<'id'>(extractParamName('id(aue(ee{2,3}\\))?/:b(hey)'))
+expectType<'id'>(extractParamName('id(e)+:d(c)'))
+expectType<'id'>(extractParamName('id(e)/:d(c)'))
+expectType<'id'>(extractParamName('id:d'))
+expectType<'id'>(extractParamName('id/:d'))
+expectType<'id'>(extractParamName('id?/other/:d'))
+expectType<'id'>(extractParamName('id/b'))
+expectType<'id'>(extractParamName('id+'))
+expectType<'id'>(extractParamName('id'))
+expectType<'id'>(extractParamName('id-u'))
+expectType<'id'>(extractParamName('id:u'))
+expectType<'id'>(extractParamName('id(o(\\)e)o'))
+expectType<'id'>(extractParamName('id(o(\\)e)?o'))