Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions src/utils/query.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { z } from 'zod';
import { parseBoolean, ParseBooleanError } from './parseBoolean.utils';

/**
* Normalize an optional integer query param.
*
* - `undefined`, `null`, or blank strings return `defaultValue`
* - numeric strings are trimmed then parsed as base-10 integers
* - non-integer strings return `null` (callers can map to validation errors)
*/
export function normalizeOptionalIntegerQueryParam(
raw: unknown,
defaultValue: number
): number | null {
if (raw === undefined || raw === null) {
return defaultValue;
}

const candidate = String(raw).trim();
if (!candidate) {
return defaultValue;
}

if (!/^-?\d+$/.test(candidate)) {
return null;
}

const parsed = Number.parseInt(candidate, 10);
return Number.isNaN(parsed) ? null : parsed;
}

/**
* Creates a Zod schema for safely parsing an integer query parameter.
*
Expand All @@ -19,8 +47,21 @@ export function safeIntParam(options: {
return z
.string()
.optional()
.default(String(defaultValue))
.transform(val => parseInt(val, 10))
.transform((raw, ctx) => {
const normalized = normalizeOptionalIntegerQueryParam(
raw,
defaultValue
);
if (normalized === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${label} must be an integer between ${min} and ${max}`,
});
return z.NEVER;
}

return normalized;
})
.refine(val => !Number.isNaN(val) && val >= min && val <= max, {
message: `${label} must be an integer between ${min} and ${max}`,
});
Expand Down
20 changes: 20 additions & 0 deletions src/utils/test/query.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { normalizeOptionalIntegerQueryParam } from '../query.utils';

describe('normalizeOptionalIntegerQueryParam', () => {
it('returns default when value is undefined', () => {
expect(normalizeOptionalIntegerQueryParam(undefined, 10)).toBe(10);
});

it('returns default when value is blank after trimming', () => {
expect(normalizeOptionalIntegerQueryParam(' ', 5)).toBe(5);
});

it('parses trimmed integer strings', () => {
expect(normalizeOptionalIntegerQueryParam(' 42 ', 0)).toBe(42);
});

it('rejects non-integer values', () => {
expect(normalizeOptionalIntegerQueryParam('12abc', 0)).toBeNull();
expect(normalizeOptionalIntegerQueryParam('3.14', 0)).toBeNull();
});
});
Loading