]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
refactor: improve param parser types to also parse null and accept a raw type
authorEduardo San Martin Morote <posva13@gmail.com>
Tue, 26 Aug 2025 13:50:19 +0000 (15:50 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Tue, 26 Aug 2025 13:50:19 +0000 (15:50 +0200)
packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.spec.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern-query.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.spec.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.test-d.ts
packages/router/src/experimental/route-resolver/matchers/matcher-pattern.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.spec.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/booleans.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.spec.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/integers.ts
packages/router/src/experimental/route-resolver/matchers/param-parsers/types.ts
packages/router/src/types/utils.ts

index 6293838593868b7ac67f321962f42fb0ecf7ed19..a0a650c33f10c7b30842cc963ba93dcb69e9df15 100644 (file)
@@ -40,18 +40,6 @@ describe('MatcherPatternQueryParam', () => {
       )
       expect(matcher.match({ user_id: null })).toEqual({ userId: null })
     })
-
-    it('handles missing query param', () => {
-      const matcher = new MatcherPatternQueryParam(
-        'userId',
-        'user_id',
-        'value',
-        PARAM_PARSER_DEFAULTS
-      )
-      expect(matcher.match({})).toEqual({
-        userId: undefined,
-      })
-    })
   })
 
   describe('match() - format: array', () => {
@@ -98,50 +86,6 @@ describe('MatcherPatternQueryParam', () => {
     })
   })
 
-  describe('match() - format: both', () => {
-    it('preserves single string value', () => {
-      const matcher = new MatcherPatternQueryParam(
-        'data',
-        'value',
-        'both',
-        PARAM_PARSER_DEFAULTS
-      )
-      expect(matcher.match({ value: 'single' })).toEqual({ data: 'single' })
-    })
-
-    it('preserves array value', () => {
-      const matcher = new MatcherPatternQueryParam(
-        'data',
-        'values',
-        'both',
-        PARAM_PARSER_DEFAULTS
-      )
-      expect(matcher.match({ values: ['a', 'b'] })).toEqual({
-        data: ['a', 'b'],
-      })
-    })
-
-    it('preserves null', () => {
-      const matcher = new MatcherPatternQueryParam(
-        'data',
-        'value',
-        'both',
-        PARAM_PARSER_DEFAULTS
-      )
-      expect(matcher.match({ value: null })).toEqual({ data: null })
-    })
-
-    it('handles missing query param', () => {
-      const matcher = new MatcherPatternQueryParam(
-        'data',
-        'value',
-        'both',
-        PARAM_PARSER_DEFAULTS
-      )
-      expect(matcher.match({})).toEqual({ data: undefined })
-    })
-  })
-
   describe('build()', () => {
     describe('format: value', () => {
       it('builds query from single value', () => {
@@ -203,40 +147,6 @@ describe('MatcherPatternQueryParam', () => {
         expect(matcher.build({ tags: ['vue'] })).toEqual({ tag: ['vue'] })
       })
     })
-
-    describe('format: both', () => {
-      it('builds query from single value', () => {
-        const matcher = new MatcherPatternQueryParam(
-          'data',
-          'value',
-          'both',
-          PARAM_PARSER_DEFAULTS
-        )
-        expect(matcher.build({ data: 'single' })).toEqual({ value: 'single' })
-      })
-
-      it('builds query from array value', () => {
-        const matcher = new MatcherPatternQueryParam(
-          'data',
-          'values',
-          'both',
-          PARAM_PARSER_DEFAULTS
-        )
-        expect(matcher.build({ data: ['a', 'b'] })).toEqual({
-          values: ['a', 'b'],
-        })
-      })
-
-      it('builds query from null value', () => {
-        const matcher = new MatcherPatternQueryParam(
-          'data',
-          'value',
-          'both',
-          PARAM_PARSER_DEFAULTS
-        )
-        expect(matcher.build({ data: null })).toEqual({ value: null })
-      })
-    })
   })
 
   describe('default values', () => {
@@ -323,14 +233,27 @@ describe('MatcherPatternQueryParam', () => {
   })
 
   describe('missing query parameters', () => {
-    it('returns undefined when query param missing with parser and no default', () => {
+    it('handles missing query param with default', () => {
       const matcher = new MatcherPatternQueryParam(
-        'count',
-        'c',
+        'userId',
+        'user_id',
         'value',
-        PARAM_PARSER_INT
+        PARAM_PARSER_DEFAULTS,
+        'default'
       )
-      expect(matcher.match({ other: 'value' })).toEqual({ count: undefined })
+      expect(matcher.match({})).toEqual({
+        userId: 'default',
+      })
+    })
+
+    it('throws if a required param is missing and no default', () => {
+      const matcher = new MatcherPatternQueryParam(
+        'userId',
+        'user_id',
+        'value',
+        PARAM_PARSER_DEFAULTS
+      )
+      expect(() => matcher.match({})).toThrow(MatchMiss)
     })
 
     it('uses default when query param missing', () => {
@@ -419,14 +342,15 @@ describe('MatcherPatternQueryParam', () => {
           'test',
           'test_param',
           'value',
-          {}
+          {},
+          'default'
         )
         // Should use PARAM_PARSER_DEFAULTS.get which returns value ?? null
         expect(matcher.match({ test_param: 'value' })).toEqual({
           test: 'value',
         })
         expect(matcher.match({ test_param: null })).toEqual({ test: null })
-        expect(matcher.match({})).toEqual({ test: undefined })
+        expect(matcher.match({})).toEqual({ test: 'default' })
       })
 
       it('should handle array format with missing get method', () => {
@@ -444,22 +368,6 @@ describe('MatcherPatternQueryParam', () => {
           test: ['single'],
         })
       })
-
-      it('should handle both format with missing get method', () => {
-        const matcher = new MatcherPatternQueryParam(
-          'test',
-          'test_param',
-          'both',
-          {}
-        )
-        // Should use PARAM_PARSER_DEFAULTS.get which returns value ?? null
-        expect(matcher.match({ test_param: 'value' })).toEqual({
-          test: 'value',
-        })
-        expect(matcher.match({ test_param: ['a', 'b'] })).toEqual({
-          test: ['a', 'b'],
-        })
-      })
     })
 
     describe('build', () => {
@@ -498,22 +406,6 @@ describe('MatcherPatternQueryParam', () => {
           test_param: ['1', 'true'],
         })
       })
-
-      it('should handle both format with missing set method', () => {
-        const matcher = new MatcherPatternQueryParam(
-          'test',
-          'test_param',
-          'both',
-          {}
-        )
-        // Should use PARAM_PARSER_DEFAULTS.set
-        expect(matcher.build({ test: 'value' })).toEqual({
-          test_param: 'value',
-        })
-        expect(matcher.build({ test: ['a', 'b'] })).toEqual({
-          test_param: ['a', 'b'],
-        })
-      })
     })
   })
 })
index 183065173022ea8c3bcc6609fb6a7c1a20d2cbde..327be6a97b332ee7d8a4c84a0f6d0caad9a6ffa9 100644 (file)
@@ -6,6 +6,7 @@ import {
   MatcherQueryParams,
 } from './matcher-pattern'
 import { ParamParser, PARAM_PARSER_DEFAULTS } from './param-parsers'
+import { miss } from './errors'
 
 /**
  * Handles the `query` part of a URL. It can transform a query object into an
@@ -65,7 +66,7 @@ export class MatcherPatternQueryParam<T, ParamName extends string>
 
       // if we have no values, we want to fall back to the default value
       if (
-        (this.format === 'both' || this.defaultValue !== undefined) &&
+        this.defaultValue !== undefined &&
         (value as unknown[]).length === 0
       ) {
         value = undefined
@@ -86,9 +87,16 @@ export class MatcherPatternQueryParam<T, ParamName extends string>
       }
     }
 
+    // miss if there is no default and there was no value in the query
+    if (value === undefined) {
+      if (this.defaultValue === undefined) {
+        throw miss()
+      }
+      value = toValue(this.defaultValue)
+    }
+
     return {
-      [this.paramName]:
-        value === undefined ? toValue(this.defaultValue) : value,
+      [this.paramName]: value,
       // This is a TS limitation
     } as Record<ParamName, T>
   }
index 756081964fbd6949cd8093389a590c39e6acd348..79e796f588f4b78b532a8a72f1cee4d960c96b60 100644 (file)
@@ -4,6 +4,8 @@ import {
   MatcherPatternPathDynamic,
 } from './matcher-pattern'
 import { MatcherPatternPathStar } from './matcher-pattern-path-star'
+import { miss } from './errors'
+import { definePathParamParser } from './param-parsers/types'
 
 describe('MatcherPatternPathStatic', () => {
   describe('match()', () => {
@@ -415,4 +417,48 @@ describe('MatcherPatternPathDynamic', () => {
     expect(pattern.build({ teamId: ['123', '456'] })).toBe('/teams/123/456/')
     expect(pattern.build({ teamId: [] })).toBe('/teams/')
   })
+
+  describe('custom param parsers', () => {
+    const doubleParser = definePathParamParser({
+      get: (v: string | null) => {
+        const value = Number(v) * 2
+        if (!Number.isFinite(value)) {
+          throw miss()
+        }
+        return value
+      },
+      set: (v: number | null) => (v == null ? null : String(v / 2)),
+    })
+
+    it('single regular param', () => {
+      const pattern = new MatcherPatternPathDynamic(
+        /^\/teams\/([^/]+?)$/i,
+        {
+          teamId: [doubleParser],
+        },
+        ['teams', 1]
+      )
+
+      expect(pattern.match('/teams/123')).toEqual({ teamId: 246 })
+      expect(() => pattern.match('/teams/abc')).toThrow()
+      expect(pattern.build({ teamId: 246 })).toBe('/teams/123')
+    })
+
+    it('can transform optional params', () => {
+      const pattern = new MatcherPatternPathDynamic(
+        /^\/teams(?:\/([^/]+?))?$/i,
+        {
+          teamId: [doubleParser, false, true],
+        },
+        ['teams', 1]
+      )
+
+      expect(pattern.match('/teams')).toEqual({ teamId: 0 })
+      expect(pattern.match('/teams/123')).toEqual({ teamId: 246 })
+      expect(() => pattern.match('/teams/abc')).toThrow()
+      expect(pattern.build({ teamId: 246 })).toBe('/teams/123')
+      expect(pattern.build({ teamId: 0 })).toBe('/teams/0')
+      expect(pattern.build({ teamId: null })).toBe('/teams')
+    })
+  })
 })
index 56a9a483c6ec80147f8faac3afefdef28903dac1..42df251a83e3bad2002f0c2b4cdddcb816216aa7 100644 (file)
@@ -1,8 +1,8 @@
 import { describe, expectTypeOf, it } from 'vitest'
 import { MatcherPatternPathDynamic } from './matcher-pattern'
-import { PARAM_INTEGER_SINGLE } from './param-parsers/integers'
 import { PATH_PARAM_PARSER_DEFAULTS } from './param-parsers'
 import { PATH_PARAM_SINGLE_DEFAULT } from './param-parsers'
+import { definePathParamParser } from './param-parsers/types'
 
 describe('MatcherPatternPathDynamic', () => {
   it('can be generic', () => {
@@ -11,7 +11,6 @@ describe('MatcherPatternPathDynamic', () => {
       { userId: [PATH_PARAM_PARSER_DEFAULTS] },
       ['users', 1]
     )
-
     expectTypeOf(matcher.match('/users/123')).toEqualTypeOf<{
       userId: string | string[] | null
     }>()
@@ -49,11 +48,33 @@ describe('MatcherPatternPathDynamic', () => {
   })
 
   it('can be a custom type', () => {
+    // naive number parser but types should be good
+    const numberParser = definePathParamParser({
+      get: value => {
+        return Number(value)
+      },
+      set: (value: number | null) => {
+        return String(value ?? 0)
+      },
+    })
+
+    expectTypeOf(numberParser.get('0')).toEqualTypeOf<number>()
+    expectTypeOf(numberParser.set(0)).toEqualTypeOf<string>()
+    expectTypeOf(numberParser.set(null)).toEqualTypeOf<string>()
+    numberParser.get(
+      // @ts-expect-error: must be a string
+      null
+    )
+    numberParser.set(
+      // @ts-expect-error: must be a number or null
+      '0'
+    )
+
     const matcher = new MatcherPatternPathDynamic(
       /^\/profiles\/([^/]+)$/i,
       {
         userId: [
-          PARAM_INTEGER_SINGLE,
+          numberParser,
           // parser: PATH_PARAM_DEFAULT_PARSER,
         ],
       },
index 8505d1a6e156dadfc084b51108293a7e4b06a3ee..b45e6ee66225c30c665493fa960a1b979cfdef6d 100644 (file)
@@ -2,13 +2,14 @@ import { identityFn } from '../../../utils'
 import { decode, encodeParam, encodePath } from '../../../encoding'
 import { warn } from '../../../warning'
 import { miss } from './errors'
-import { ParamParser } from './param-parsers/types'
+import type { ParamParser } from './param-parsers/types'
+import type { Simplify } from '../../../types/utils'
 
 /**
  * Base interface for matcher patterns that extract params from a URL.
  *
  * @template TIn - type of the input value to match against the pattern
- * @template TOut - type of the output value after matching
+ * @template TParams - type of the output value after matching
  *
  * In the case of the `path`, the `TIn` is a `string`, but in the case of the
  * query, it's the object of query params.
@@ -18,7 +19,8 @@ import { ParamParser } from './param-parsers/types'
  */
 export interface MatcherPattern<
   TIn = string,
-  TOut extends MatcherParamsFormatted = MatcherParamsFormatted,
+  TParams extends MatcherParamsFormatted = MatcherParamsFormatted,
+  TParamsRaw extends MatcherParamsFormatted = TParams,
 > {
   /**
    * Matches a serialized params value against the pattern.
@@ -27,7 +29,7 @@ export interface MatcherPattern<
    * @throws {MatchMiss} if the value doesn't match
    * @returns parsed params object
    */
-  match(value: TIn): TOut
+  match(value: TIn): TParams
 
   /**
    * Build a serializable value from parsed params. Should apply encoding if the
@@ -37,7 +39,7 @@ export interface MatcherPattern<
    * @param value - params value to parse
    * @returns serialized params value
    */
-  build(params: TOut): TIn
+  build(params: TParamsRaw): TIn
 }
 
 /**
@@ -47,7 +49,8 @@ export interface MatcherPattern<
 export interface MatcherPatternPath<
   // TODO: should we allow to not return anything? It's valid to spread null and undefined
   TParams extends MatcherParamsFormatted = MatcherParamsFormatted, // | null // | undefined // | void // so it might be a bit more convenient
-> extends MatcherPattern<string, TParams> {}
+  TParamsRaw extends MatcherParamsFormatted = TParams,
+> extends MatcherPattern<string, TParams, TParamsRaw> {}
 
 /**
  * Allows matching a static path.
@@ -69,7 +72,7 @@ export class MatcherPatternPathStatic
    */
   private pathi: string
 
-  constructor(private path: string) {
+  constructor(readonly path: string) {
     this.pathi = path.toLowerCase()
   }
 
@@ -89,13 +92,14 @@ export class MatcherPatternPathStatic
  * Options for param parsers in {@link MatcherPatternPathDynamic}.
  */
 export type MatcherPatternPathDynamic_ParamOptions<
-  TIn extends string | string[] | null = string | string[] | null,
-  TOut = string | string[] | null,
-> = [
+  TUrlParam extends string | string[] | null = string | string[] | null,
+  TParam = string | string[] | null,
+  TParamRaw = TParam,
+> = readonly [
   /**
    * Param parser to use for this param.
    */
-  parser?: ParamParser<TOut, TIn>,
+  parser?: ParamParser<TParam, TUrlParam, TParamRaw>,
 
   /**
    * Is tha param a repeatable param and should be converted to an array
@@ -115,9 +119,20 @@ export type MatcherPatternPathDynamic_ParamOptions<
 type ExtractParamTypeFromOptions<TParamsOptions> = {
   [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions<
     any,
-    infer TOut
+    infer TParam,
+    any
+  >
+    ? TParam
+    : never
+}
+
+type ExtractLocationParamTypeFromOptions<TParamsOptions> = {
+  [K in keyof TParamsOptions]: TParamsOptions[K] extends MatcherPatternPathDynamic_ParamOptions<
+    any,
+    any,
+    infer TParamRaw
   >
-    ? TOut
+    ? TParamRaw
     : never
 }
 
@@ -136,7 +151,11 @@ export class MatcherPatternPathDynamic<
   // TODO: | EmptyObject ?
   // TParamsOptions extends Record<string, MatcherPatternPathCustomParamOptions>,
   // TParams extends MatcherParamsFormatted = ExtractParamTypeFromOptions<TParamsOptions>
-> implements MatcherPatternPath<ExtractParamTypeFromOptions<TParamsOptions>>
+> implements
+    MatcherPatternPath<
+      ExtractParamTypeFromOptions<TParamsOptions>,
+      ExtractLocationParamTypeFromOptions<TParamsOptions>
+    >
 {
   /**
    * Cached keys of the {@link params} object.
@@ -158,7 +177,7 @@ export class MatcherPatternPathDynamic<
     this.paramsKeys = Object.keys(this.params) as Array<keyof TParamsOptions>
   }
 
-  match(path: string): ExtractParamTypeFromOptions<TParamsOptions> {
+  match(path: string): Simplify<ExtractParamTypeFromOptions<TParamsOptions>> {
     if (
       this.trailingSlash != null &&
       this.trailingSlash === !path.endsWith('/')
@@ -196,7 +215,9 @@ export class MatcherPatternPathDynamic<
     return params
   }
 
-  build(params: ExtractParamTypeFromOptions<TParamsOptions>): string {
+  build(
+    params: Simplify<ExtractLocationParamTypeFromOptions<TParamsOptions>>
+  ): string {
     let paramIndex = 0
     let paramName: keyof TParamsOptions
     let parser: (TParamsOptions &
@@ -290,6 +311,10 @@ export type EmptyParams = Record<PropertyKey, never> // TODO: move to matcher-pa
 /**
  * Possible values for query params in a matcher.
  */
-export type MatcherQueryParamsValue = string | null | Array<string | null>
+export type MatcherQueryParamsValue =
+  | string
+  | null
+  | undefined
+  | Array<string | null>
 
 export type MatcherQueryParams = Record<string, MatcherQueryParamsValue>
index 8d597ed67205d769adbc6e38ee118d1a8762e9a7..3d24152a538023d3ac33a8be425ab81ae238b0c8 100644 (file)
 import { describe, expect, it } from 'vitest'
-import {
-  PARAM_BOOLEAN_SINGLE,
-  PARAM_BOOLEAN_OPTIONAL,
-  PARAM_BOOLEAN_REPEATABLE,
-  PARAM_PARSER_BOOL,
-} from './booleans'
+import { PARAM_PARSER_BOOL } from './booleans'
 
-describe('PARAM_BOOLEAN_SINGLE', () => {
-  describe('get()', () => {
+describe('PARAM_PARSER_BOOL', () => {
+  describe('get() - Single Values', () => {
     it('parses true values', () => {
-      expect(PARAM_BOOLEAN_SINGLE.get('true')).toBe(true)
-      expect(PARAM_BOOLEAN_SINGLE.get('TRUE')).toBe(true)
-      expect(PARAM_BOOLEAN_SINGLE.get('True')).toBe(true)
+      expect(PARAM_PARSER_BOOL.get('true')).toBe(true)
+      expect(PARAM_PARSER_BOOL.get('TRUE')).toBe(true)
+      expect(PARAM_PARSER_BOOL.get('True')).toBe(true)
+      expect(PARAM_PARSER_BOOL.get('TrUe')).toBe(true)
+      expect(PARAM_PARSER_BOOL.get('tRUE')).toBe(true)
     })
 
     it('parses false values', () => {
-      expect(PARAM_BOOLEAN_SINGLE.get('false')).toBe(false)
-      expect(PARAM_BOOLEAN_SINGLE.get('FALSE')).toBe(false)
-      expect(PARAM_BOOLEAN_SINGLE.get('False')).toBe(false)
+      expect(PARAM_PARSER_BOOL.get('false')).toBe(false)
+      expect(PARAM_PARSER_BOOL.get('FALSE')).toBe(false)
+      expect(PARAM_PARSER_BOOL.get('False')).toBe(false)
+      expect(PARAM_PARSER_BOOL.get('FaLsE')).toBe(false)
+      expect(PARAM_PARSER_BOOL.get('fALSE')).toBe(false)
     })
 
-    it('throws for invalid values', () => {
-      expect(() => PARAM_BOOLEAN_SINGLE.get('yes')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('no')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('1')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('0')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('on')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('off')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('maybe')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('invalid')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('true1')).toThrow()
-      expect(() => PARAM_BOOLEAN_SINGLE.get('falsy')).toThrow()
+    it('returns true for null values (param present without value)', () => {
+      expect(PARAM_PARSER_BOOL.get(null)).toBe(true)
     })
 
-    it('returns false for null or empty values', () => {
-      expect(PARAM_BOOLEAN_SINGLE.get(null)).toBe(false)
-      expect(PARAM_BOOLEAN_SINGLE.get('')).toBe(false)
+    it('returns undefined for undefined values (param missing)', () => {
+      expect(PARAM_PARSER_BOOL.get(undefined)).toBe(undefined)
     })
-  })
 
-  describe('set()', () => {
-    it('converts boolean to string', () => {
-      expect(PARAM_BOOLEAN_SINGLE.set(true)).toBe('true')
-      expect(PARAM_BOOLEAN_SINGLE.set(false)).toBe('false')
-    })
-
-    it('converts null to false string', () => {
-      expect(PARAM_BOOLEAN_SINGLE.set(null)).toBe('false')
+    it('throws for invalid string values', () => {
+      expect(() => PARAM_PARSER_BOOL.get('')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('yes')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('no')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('1')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('0')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('on')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('off')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('maybe')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('invalid')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('true1')).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get('falsy')).toThrow()
     })
   })
-})
 
-describe('PARAM_BOOLEAN_OPTIONAL', () => {
-  describe('get()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_BOOLEAN_OPTIONAL.get(null)).toBe(null)
+  describe('get() - Array Values', () => {
+    it('parses arrays of valid boolean values', () => {
+      expect(PARAM_PARSER_BOOL.get(['true', 'false'])).toEqual([true, false])
+      expect(PARAM_PARSER_BOOL.get(['TRUE', 'FALSE'])).toEqual([true, false])
+      expect(PARAM_PARSER_BOOL.get(['True', 'False'])).toEqual([true, false])
+      expect(PARAM_PARSER_BOOL.get(['true', 'false', 'TRUE', 'FALSE'])).toEqual(
+        [true, false, true, false]
+      )
     })
 
-    it('parses valid values', () => {
-      expect(PARAM_BOOLEAN_OPTIONAL.get('true')).toBe(true)
-      expect(PARAM_BOOLEAN_OPTIONAL.get('false')).toBe(false)
+    it('handles empty arrays', () => {
+      expect(PARAM_PARSER_BOOL.get([])).toEqual([])
     })
 
-    it('throws for invalid values', () => {
-      expect(() => PARAM_BOOLEAN_OPTIONAL.get('invalid')).toThrow()
+    it('handles arrays with null values (converts null to true)', () => {
+      expect(PARAM_PARSER_BOOL.get([null])).toEqual([true])
+      expect(PARAM_PARSER_BOOL.get(['true', null, 'false'])).toEqual([
+        true,
+        true,
+        false,
+      ])
+      expect(PARAM_PARSER_BOOL.get([null, null])).toEqual([true, true])
     })
-  })
 
-  describe('set()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_BOOLEAN_OPTIONAL.set(null)).toBe(null)
+    it('throws for arrays with invalid values', () => {
+      expect(() => PARAM_PARSER_BOOL.get(['true', 'invalid'])).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get(['invalid'])).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get(['true', ''])).toThrow()
+      expect(() => PARAM_PARSER_BOOL.get(['yes', 'no'])).toThrow()
     })
+  })
 
-    it('converts boolean to string', () => {
-      expect(PARAM_BOOLEAN_OPTIONAL.set(true)).toBe('true')
-      expect(PARAM_BOOLEAN_OPTIONAL.set(false)).toBe('false')
+  describe('set() - Single Values', () => {
+    it('converts boolean values to strings', () => {
+      expect(PARAM_PARSER_BOOL.set(true)).toBe('true')
+      expect(PARAM_PARSER_BOOL.set(false)).toBe('false')
     })
-  })
-})
 
-describe('PARAM_BOOLEAN_REPEATABLE', () => {
-  describe('get()', () => {
-    it('parses array of boolean values', () => {
-      expect(
-        PARAM_BOOLEAN_REPEATABLE.get(['true', 'false', 'TRUE', 'FALSE'])
-      ).toEqual([true, false, true, false])
+    it('preserves null values', () => {
+      expect(PARAM_PARSER_BOOL.set(null)).toBe(null)
     })
 
-    it('throws for invalid values in array', () => {
-      expect(() => PARAM_BOOLEAN_REPEATABLE.get(['true', 'invalid'])).toThrow()
+    it('preserves undefined values', () => {
+      expect(PARAM_PARSER_BOOL.set(undefined)).toBe(undefined)
     })
   })
 
-  describe('set()', () => {
-    it('converts array of booleans to strings', () => {
-      expect(PARAM_BOOLEAN_REPEATABLE.set([true, false, true])).toEqual([
+  describe('set() - Array Values', () => {
+    it('converts arrays of booleans to arrays of strings', () => {
+      expect(PARAM_PARSER_BOOL.set([true])).toEqual(['true'])
+      expect(PARAM_PARSER_BOOL.set([false])).toEqual(['false'])
+      expect(PARAM_PARSER_BOOL.set([true, false])).toEqual(['true', 'false'])
+      expect(PARAM_PARSER_BOOL.set([true, false, true])).toEqual([
         'true',
         'false',
         'true',
       ])
     })
-  })
-})
-
-describe('PARAM_PARSER_BOOL', () => {
-  describe('get()', () => {
-    it('handles single values', () => {
-      expect(PARAM_PARSER_BOOL.get('true')).toBe(true)
-      expect(PARAM_PARSER_BOOL.get('false')).toBe(false)
-    })
-
-    it('handles null values', () => {
-      expect(PARAM_PARSER_BOOL.get(null)).toBe(false)
-    })
-
-    it('handles array values', () => {
-      expect(PARAM_PARSER_BOOL.get(['true', 'false'])).toEqual([true, false])
-    })
-  })
 
-  describe('set()', () => {
-    it('handles single values', () => {
-      expect(PARAM_PARSER_BOOL.set(true)).toBe('true')
-      expect(PARAM_PARSER_BOOL.set(false)).toBe('false')
-    })
-
-    it('handles null values', () => {
-      expect(PARAM_PARSER_BOOL.set(null)).toBe('false')
-    })
-
-    it('handles array values', () => {
-      expect(PARAM_PARSER_BOOL.set([true, false])).toEqual(['true', 'false'])
+    it('handles empty arrays', () => {
+      expect(PARAM_PARSER_BOOL.set([])).toEqual([])
     })
   })
 })
index d1e3dc119c7e559f3da1784de8cf3ddc271a4b85..58c8c74f06208892ffa180ae2050714096aa7424 100644 (file)
@@ -1,9 +1,12 @@
 import { miss } from '../errors'
 import { ParamParser } from './types'
 
-export const PARAM_BOOLEAN_SINGLE = {
-  get: (value: string | null) => {
-    if (!value) return false
+const PARAM_BOOLEAN_SINGLE = {
+  get: (value: string | null | undefined) => {
+    // we want to differentiate between the absence of a value
+    if (value === undefined) return undefined
+
+    if (value == null) return true
 
     const lowercaseValue = value.toLowerCase()
 
@@ -17,28 +20,22 @@ export const PARAM_BOOLEAN_SINGLE = {
 
     throw miss()
   },
-  set: (value: boolean | null) => String(!!value),
-} satisfies ParamParser<boolean, string | null>
-
-export const PARAM_BOOLEAN_OPTIONAL = {
-  get: (value: string | null) =>
-    value == null ? null : PARAM_BOOLEAN_SINGLE.get(value),
-  set: (value: boolean | null) =>
-    value != null ? PARAM_BOOLEAN_SINGLE.set(value) : null,
-} satisfies ParamParser<boolean | null, string | null>
-
-export const PARAM_BOOLEAN_REPEATABLE = {
-  get: (value: (string | null)[]) => value.map(PARAM_BOOLEAN_SINGLE.get),
-  set: (value: boolean[]) => value.map(PARAM_BOOLEAN_SINGLE.set),
+  set: (value: boolean | null | undefined) =>
+    value == null ? value : String(value),
+} satisfies ParamParser<boolean | null | undefined, string | null | undefined>
+
+const PARAM_BOOLEAN_REPEATABLE = {
+  get: (value: (string | null)[]) =>
+    value.map(v => {
+      const result = PARAM_BOOLEAN_SINGLE.get(v)
+      // Filter out undefined values to ensure arrays only contain booleans
+      return result === undefined ? false : result
+    }),
+  set: (value: boolean[]) =>
+    // since v is always a boolean, set always returns a string
+    value.map(v => PARAM_BOOLEAN_SINGLE.set(v) as string),
 } satisfies ParamParser<boolean[], (string | null)[]>
 
-export const PARAM_BOOLEAN_REPEATABLE_OPTIONAL = {
-  get: (value: string[] | null) =>
-    value == null ? null : PARAM_BOOLEAN_REPEATABLE.get(value),
-  set: (value: boolean[] | null) =>
-    value != null ? PARAM_BOOLEAN_REPEATABLE.set(value) : null,
-} satisfies ParamParser<boolean[] | null, string[] | null>
-
 /**
  * Native Param parser for booleans.
  *
@@ -53,4 +50,4 @@ export const PARAM_PARSER_BOOL = {
     Array.isArray(value)
       ? PARAM_BOOLEAN_REPEATABLE.set(value)
       : PARAM_BOOLEAN_SINGLE.set(value),
-} satisfies ParamParser<boolean | boolean[] | null>
+} satisfies ParamParser<boolean | boolean[] | null | undefined>
index ca0727b343f5c9a781da555196978e7534604bcc..2c7fb5e1046b76346ee6bc4bc7fc7a0e0bf7e075 100644 (file)
 import { describe, expect, it } from 'vitest'
-import {
-  PARAM_INTEGER_SINGLE,
-  PARAM_INTEGER_OPTIONAL,
-  PARAM_INTEGER_REPEATABLE,
-  PARAM_INTEGER_REPEATABLE_OPTIONAL,
-  PARAM_PARSER_INT,
-} from './integers'
-
-describe('PARAM_INTEGER_SINGLE', () => {
-  describe('get()', () => {
-    it('parses valid integers', () => {
-      expect(PARAM_INTEGER_SINGLE.get('0')).toBe(0)
-      expect(PARAM_INTEGER_SINGLE.get('1')).toBe(1)
-      expect(PARAM_INTEGER_SINGLE.get('42')).toBe(42)
-      expect(PARAM_INTEGER_SINGLE.get('-1')).toBe(-1)
-      expect(PARAM_INTEGER_SINGLE.get('-999')).toBe(-999)
-      expect(PARAM_INTEGER_SINGLE.get('2147483647')).toBe(2147483647)
-    })
-
-    it('throws for decimal numbers', () => {
-      expect(() => PARAM_INTEGER_SINGLE.get('1.5')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('3.14159')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('0.1')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('-2.5')).toThrow()
-    })
+import { PARAM_PARSER_INT } from './integers'
 
-    it('throws for non-numeric strings', () => {
-      expect(() => PARAM_INTEGER_SINGLE.get('abc')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('12abc')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('abc12')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('true')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('false')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('NaN')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('Infinity')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('-Infinity')).toThrow()
+describe('PARAM_PARSER_INT', () => {
+  describe('get() - Single Values', () => {
+    it('parses valid integer strings', () => {
+      expect(PARAM_PARSER_INT.get('0')).toBe(0)
+      expect(PARAM_PARSER_INT.get('1')).toBe(1)
+      expect(PARAM_PARSER_INT.get('42')).toBe(42)
+      expect(PARAM_PARSER_INT.get('-1')).toBe(-1)
+      expect(PARAM_PARSER_INT.get('-999')).toBe(-999)
+      expect(PARAM_PARSER_INT.get('2147483647')).toBe(2147483647)
     })
 
-    it('throws for empty strings', () => {
-      expect(() => PARAM_INTEGER_SINGLE.get('')).toThrow()
+    it('parses numbers with leading/trailing whitespace', () => {
+      expect(PARAM_PARSER_INT.get(' 42')).toBe(42)
+      expect(PARAM_PARSER_INT.get('42 ')).toBe(42)
+      expect(PARAM_PARSER_INT.get(' 42 ')).toBe(42)
     })
 
     it('parses whitespace strings as zero', () => {
-      expect(PARAM_INTEGER_SINGLE.get(' ')).toBe(0)
-      expect(PARAM_INTEGER_SINGLE.get('  ')).toBe(0)
-      expect(PARAM_INTEGER_SINGLE.get('\n')).toBe(0)
-      expect(PARAM_INTEGER_SINGLE.get('\t')).toBe(0)
-    })
-
-    it('throws for null', () => {
-      expect(() => PARAM_INTEGER_SINGLE.get(null)).toThrow()
-    })
-
-    it('parses numbers with leading/trailing whitespace', () => {
-      expect(PARAM_INTEGER_SINGLE.get(' 42')).toBe(42)
-      expect(PARAM_INTEGER_SINGLE.get('42 ')).toBe(42)
-      expect(PARAM_INTEGER_SINGLE.get(' 42 ')).toBe(42)
+      expect(PARAM_PARSER_INT.get(' ')).toBe(0)
+      expect(PARAM_PARSER_INT.get('  ')).toBe(0)
+      expect(PARAM_PARSER_INT.get('\n')).toBe(0)
+      expect(PARAM_PARSER_INT.get('\t')).toBe(0)
     })
 
     it('parses valid scientific notation as integers', () => {
-      expect(PARAM_INTEGER_SINGLE.get('1e5')).toBe(100000)
-      expect(PARAM_INTEGER_SINGLE.get('1e2')).toBe(100)
-    })
-
-    it('parses scientific notation that results in large integers', () => {
-      expect(PARAM_INTEGER_SINGLE.get('2.5e10')).toBe(25000000000)
-      expect(PARAM_INTEGER_SINGLE.get('1.5e2')).toBe(150)
+      expect(PARAM_PARSER_INT.get('1e5')).toBe(100000)
+      expect(PARAM_PARSER_INT.get('1e2')).toBe(100)
+      expect(PARAM_PARSER_INT.get('2.5e10')).toBe(25000000000)
+      expect(PARAM_PARSER_INT.get('1.5e2')).toBe(150)
     })
 
-    it('throws for scientific notation that results in decimals', () => {
-      expect(() => PARAM_INTEGER_SINGLE.get('1e-1')).toThrow()
-      expect(() => PARAM_INTEGER_SINGLE.get('1e-2')).toThrow()
-    })
-  })
-
-  describe('set()', () => {
-    it('converts integers to strings', () => {
-      expect(PARAM_INTEGER_SINGLE.set(0)).toBe('0')
-      expect(PARAM_INTEGER_SINGLE.set(1)).toBe('1')
-      expect(PARAM_INTEGER_SINGLE.set(42)).toBe('42')
-      expect(PARAM_INTEGER_SINGLE.set(-1)).toBe('-1')
-      expect(PARAM_INTEGER_SINGLE.set(-999)).toBe('-999')
-      expect(PARAM_INTEGER_SINGLE.set(2147483647)).toBe('2147483647')
-    })
-  })
-})
-
-describe('PARAM_INTEGER_OPTIONAL', () => {
-  describe('get()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_INTEGER_OPTIONAL.get(null)).toBe(null)
+    it('returns null for null values', () => {
+      expect(PARAM_PARSER_INT.get(null)).toBe(null)
     })
 
-    it('parses valid integers', () => {
-      expect(PARAM_INTEGER_OPTIONAL.get('0')).toBe(0)
-      expect(PARAM_INTEGER_OPTIONAL.get('42')).toBe(42)
-      expect(PARAM_INTEGER_OPTIONAL.get('-1')).toBe(-1)
+    it('throws for decimal numbers', () => {
+      expect(() => PARAM_PARSER_INT.get('1.5')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('3.14159')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('0.1')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('-2.5')).toThrow()
     })
 
-    it('throws for invalid values', () => {
-      expect(() => PARAM_INTEGER_OPTIONAL.get('invalid')).toThrow()
-      expect(() => PARAM_INTEGER_OPTIONAL.get('1.5')).toThrow()
-      expect(() => PARAM_INTEGER_OPTIONAL.get('')).toThrow()
+    it('throws for scientific notation that results in decimals', () => {
+      expect(() => PARAM_PARSER_INT.get('1e-1')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('1e-2')).toThrow()
     })
-  })
 
-  describe('set()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_INTEGER_OPTIONAL.set(null)).toBe(null)
+    it('throws for non-numeric strings', () => {
+      expect(() => PARAM_PARSER_INT.get('abc')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('12abc')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('abc12')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('true')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('false')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('NaN')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('Infinity')).toThrow()
+      expect(() => PARAM_PARSER_INT.get('-Infinity')).toThrow()
     })
 
-    it('converts integers to strings', () => {
-      expect(PARAM_INTEGER_OPTIONAL.set(0)).toBe('0')
-      expect(PARAM_INTEGER_OPTIONAL.set(42)).toBe('42')
-      expect(PARAM_INTEGER_OPTIONAL.set(-1)).toBe('-1')
+    it('throws for empty strings', () => {
+      expect(() => PARAM_PARSER_INT.get('')).toThrow()
     })
   })
-})
 
-describe('PARAM_INTEGER_REPEATABLE', () => {
-  describe('get()', () => {
-    it('parses array of integer values', () => {
-      expect(
-        PARAM_INTEGER_REPEATABLE.get(['0', '1', '42', '-1', '-999'])
-      ).toEqual([0, 1, 42, -1, -999])
+  describe('get() - Array Values', () => {
+    it('parses arrays of valid integer strings', () => {
+      expect(PARAM_PARSER_INT.get(['0', '1', '42', '-1', '-999'])).toEqual([
+        0, 1, 42, -1, -999,
+      ])
+      expect(PARAM_PARSER_INT.get(['2147483647'])).toEqual([2147483647])
     })
 
-    it('handles empty array', () => {
-      expect(PARAM_INTEGER_REPEATABLE.get([])).toEqual([])
+    it('handles empty arrays', () => {
+      expect(PARAM_PARSER_INT.get([])).toEqual([])
     })
 
-    it('throws for invalid values in array', () => {
-      expect(() => PARAM_INTEGER_REPEATABLE.get(['42', 'invalid'])).toThrow()
-      expect(() => PARAM_INTEGER_REPEATABLE.get(['1', '2.5'])).toThrow()
-      expect(() => PARAM_INTEGER_REPEATABLE.get(['1', ''])).toThrow()
+    it('throws for arrays with decimal numbers', () => {
+      expect(() => PARAM_PARSER_INT.get(['42', '1.5'])).toThrow()
+      expect(() => PARAM_PARSER_INT.get(['3.14159'])).toThrow()
     })
 
-    it('throws if any element is null', () => {
-      expect(() => PARAM_INTEGER_REPEATABLE.get(['1', null, '3'])).toThrow()
+    it('throws for arrays with non-numeric strings', () => {
+      expect(() => PARAM_PARSER_INT.get(['42', 'invalid'])).toThrow()
+      expect(() => PARAM_PARSER_INT.get(['1', '12abc'])).toThrow()
+      expect(() => PARAM_PARSER_INT.get(['true', 'false'])).toThrow()
     })
-  })
 
-  describe('set()', () => {
-    it('converts array of integers to strings', () => {
-      expect(PARAM_INTEGER_REPEATABLE.set([0, 1, 42, -1, -999])).toEqual([
-        '0',
-        '1',
-        '42',
-        '-1',
-        '-999',
-      ])
+    it('throws for arrays with empty strings', () => {
+      expect(() => PARAM_PARSER_INT.get(['1', ''])).toThrow()
+      expect(() => PARAM_PARSER_INT.get(['', '2'])).toThrow()
     })
 
-    it('handles empty array', () => {
-      expect(PARAM_INTEGER_REPEATABLE.set([])).toEqual([])
+    it('throws for arrays with null values', () => {
+      expect(() => PARAM_PARSER_INT.get(['1', null, '3'])).toThrow()
+      expect(() => PARAM_PARSER_INT.get([null])).toThrow()
     })
   })
-})
-
-describe('PARAM_INTEGER_REPEATABLE_OPTIONAL', () => {
-  describe('get()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get(null)).toBe(null)
-    })
-
-    it('parses array of integer values', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get(['0', '42', '-1'])).toEqual([
-        0, 42, -1,
-      ])
-    })
 
-    it('handles empty array', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.get([])).toEqual([])
+  describe('set() - Single Values', () => {
+    it('converts integers to strings', () => {
+      expect(PARAM_PARSER_INT.set(0)).toBe('0')
+      expect(PARAM_PARSER_INT.set(1)).toBe('1')
+      expect(PARAM_PARSER_INT.set(42)).toBe('42')
+      expect(PARAM_PARSER_INT.set(-1)).toBe('-1')
+      expect(PARAM_PARSER_INT.set(-999)).toBe('-999')
+      expect(PARAM_PARSER_INT.set(2147483647)).toBe('2147483647')
     })
 
-    it('throws for invalid values in array', () => {
-      expect(() =>
-        PARAM_INTEGER_REPEATABLE_OPTIONAL.get(['42', 'invalid'])
-      ).toThrow()
+    it('returns null for null values', () => {
+      expect(PARAM_PARSER_INT.set(null)).toBe(null)
     })
   })
 
-  describe('set()', () => {
-    it('returns null for null input', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set(null)).toBe(null)
-    })
-
-    it('converts array of integers to strings', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set([0, 42, -1])).toEqual([
+  describe('set() - Array Values', () => {
+    it('converts arrays of integers to arrays of strings', () => {
+      expect(PARAM_PARSER_INT.set([0, 1, 42, -1, -999])).toEqual([
         '0',
+        '1',
         '42',
         '-1',
+        '-999',
       ])
+      expect(PARAM_PARSER_INT.set([2147483647])).toEqual(['2147483647'])
     })
 
-    it('handles empty array', () => {
-      expect(PARAM_INTEGER_REPEATABLE_OPTIONAL.set([])).toEqual([])
-    })
-  })
-})
-
-describe('PARAM_PARSER_INT', () => {
-  describe('get()', () => {
-    it('handles single integer values', () => {
-      expect(PARAM_PARSER_INT.get('0')).toBe(0)
-      expect(PARAM_PARSER_INT.get('42')).toBe(42)
-      expect(PARAM_PARSER_INT.get('-1')).toBe(-1)
-    })
-
-    it('handles null values', () => {
-      expect(PARAM_PARSER_INT.get(null)).toBe(null)
-    })
-
-    it('handles array values', () => {
-      expect(PARAM_PARSER_INT.get(['0', '42', '-1'])).toEqual([0, 42, -1])
-      expect(PARAM_PARSER_INT.get([])).toEqual([])
-    })
-
-    it('throws for invalid single values', () => {
-      expect(() => PARAM_PARSER_INT.get('invalid')).toThrow()
-      expect(() => PARAM_PARSER_INT.get('1.5')).toThrow()
-    })
-
-    it('throws for invalid array values', () => {
-      expect(() => PARAM_PARSER_INT.get(['1', 'invalid'])).toThrow()
-      expect(() => PARAM_PARSER_INT.get(['1', '2.5'])).toThrow()
-    })
-  })
-
-  describe('set()', () => {
-    it('handles single integer values', () => {
-      expect(PARAM_PARSER_INT.set(0)).toBe('0')
-      expect(PARAM_PARSER_INT.set(42)).toBe('42')
-      expect(PARAM_PARSER_INT.set(-1)).toBe('-1')
-    })
-
-    it('handles null values', () => {
-      expect(PARAM_PARSER_INT.set(null)).toBe(null)
-    })
-
-    it('handles array values', () => {
-      expect(PARAM_PARSER_INT.set([0, 42, -1])).toEqual(['0', '42', '-1'])
+    it('handles empty arrays', () => {
       expect(PARAM_PARSER_INT.set([])).toEqual([])
     })
   })
index 814b63cde0dc0801713919ba55758a628f414ed5..79720483bd69d7f52b137c6b622b92e3ef5e564f 100644 (file)
@@ -1,7 +1,7 @@
 import { miss } from '../errors'
 import { ParamParser } from './types'
 
-export const PARAM_INTEGER_SINGLE = {
+const PARAM_INTEGER_SINGLE = {
   get: (value: string | null) => {
     const num = Number(value)
     if (value && Number.isInteger(num)) {
@@ -12,25 +12,11 @@ export const PARAM_INTEGER_SINGLE = {
   set: (value: number) => String(value),
 } satisfies ParamParser<number, string | null>
 
-export const PARAM_INTEGER_OPTIONAL = {
-  get: (value: string | null) =>
-    value == null ? null : PARAM_INTEGER_SINGLE.get(value),
-  set: (value: number | null) =>
-    value != null ? PARAM_INTEGER_SINGLE.set(value) : null,
-} satisfies ParamParser<number | null, string | null>
-
-export const PARAM_INTEGER_REPEATABLE = {
+const PARAM_INTEGER_REPEATABLE = {
   get: (value: (string | null)[]) => value.map(PARAM_INTEGER_SINGLE.get),
   set: (value: number[]) => value.map(PARAM_INTEGER_SINGLE.set),
 } satisfies ParamParser<number[], (string | null)[]>
 
-export const PARAM_INTEGER_REPEATABLE_OPTIONAL = {
-  get: (value: string[] | null) =>
-    value == null ? null : PARAM_INTEGER_REPEATABLE.get(value),
-  set: (value: number[] | null) =>
-    value != null ? PARAM_INTEGER_REPEATABLE.set(value) : null,
-} satisfies ParamParser<number[] | null, string[] | null>
-
 /**
  * Native Param parser for integers.
  *
index 59aea004b98e5bf6bd815f11103b8764b976db96..0c051d2bb878fe2b1954c243d41f661743451ff2 100644 (file)
@@ -7,13 +7,67 @@ import { MatcherQueryParamsValue } from '../matcher-pattern'
  * @see MatcherPattern
  */
 export interface ParamParser<
-  TOut = MatcherQueryParamsValue,
-  TIn extends MatcherQueryParamsValue = MatcherQueryParamsValue,
+  // type of the param after parsing as exposed in `route.params`
+  TParam = MatcherQueryParamsValue,
+  // this is the most permissive type that can be passed to get and set, it's from the query
+  // path params stricter as they do not allow `null` within an array or `undefined`
+  TUrlParam = MatcherQueryParamsValue,
+  // the type that can be passed as a location when navigating: `router.push({ params: { }})`
+  // it's sometimes for more permissive than TParam, for example allowing nullish values
+  TParamRaw = TParam,
 > {
-  get?: (value: NoInfer<TIn>) => TOut
-  set?: (value: NoInfer<TOut>) => TIn
+  get?: (value: NoInfer<TUrlParam>) => TParam
+  set?: (value: TParamRaw) => TUrlParam
 }
 
+/**
+ * Defines a path param parser.
+ *
+ * @param parser - the parser to define. Will be returned as is.
+ *
+ * @see {@link defineQueryParamParser}
+ * @see {@link defineParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export function definePathParamParser<
+  TParam,
+  // path params are parsed by the router as these
+  // we use extend to allow infering a more specific type
+  TUrlParam extends string | string[] | null,
+  // we can allow pushing with extra values
+  TParamRaw,
+>(parser: Required<ParamParser<TParam, TUrlParam, TParamRaw>>) {
+  return parser
+}
+
+/**
+ * Defines a query param parser. Note that query params can also be used as
+ * path param parsers.
+ *
+ * @param parser - the parser to define. Will be returned as is.
+ *
+ * @see {@link definePathParamParser}
+ * @see {@link defineParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export function defineQueryParamParser<
+  TParam,
+  // we can allow pushing with extra values
+  TParamRaw = TParam,
+>(parser: Required<ParamParser<TParam, MatcherQueryParamsValue, TParamRaw>>) {
+  return parser
+}
+
+/**
+ * Alias for {@link defineQueryParamParser}. Implementing a param parser like this
+ * works for path, query, and hash params.
+ *
+ * @see {@link defineQueryParamParser}
+ * @see {@link definePathParamParser}
+ */
+/*! #__NO_SIDE_EFFECTS__ */
+export const defineParamParser = defineQueryParamParser
+
 // TODO: I wonder if native param parsers should follow this or similar
 // these parsers can be used for both query and path params
 // export type ParamParserBoth<T> = ParamParser<T | T[] | null>
index 34881aca563b612706edc4ad70781508f90560f0..e3e77655c074f4b2c21719e480bf5d8d92d0be2b 100644 (file)
@@ -25,7 +25,7 @@ export type _Awaitable<T> = T | PromiseLike<T>
 /**
  * @internal
  */
-export type _Simplify<T> = { [K in keyof T]: T[K] }
+export type Simplify<T> = { [K in keyof T]: T[K] } & {}
 
 /**
  * @internal