Skip to content

Instantly share code, notes, and snippets.

@MarkSFrancis
Last active June 28, 2025 09:30
Show Gist options
  • Select an option

  • Save MarkSFrancis/f2c6e06fe6749e2abd162fb3d2c7ec9f to your computer and use it in GitHub Desktop.

Select an option

Save MarkSFrancis/f2c6e06fe6749e2abd162fb3d2c7ec9f to your computer and use it in GitHub Desktop.
Valibot utils for validation

Valibot Utils

Included

  • vTrimString -> validate it's a non-empty string after trimming for whitespace
  • vTrimStringOptional -> same as above, but allowing undefined. Converts empty strings (after trimming) to undefined
  • vAfter -> validate it's a base64 encoded JSON object + matches the given schema. Useful for encoded pagination variables, such as ?after={id: 10}
  • vCoerceObject -> coerce values from string to whatever the provided schema needs. Useful for parsing query strings, path params, etc.

Prerequisites

This gist also includes tests written for vitest, but you can use any test runner you like. Just swap out the vitest import

import { describe, expect, it } from 'vitest';
import { decodeAfterParamToText, getPaginationNextParam } from './afterParam';
describe('createPaginateResponse', () => {
it('should not set next if the limit is not reached', () => {
const result = getPaginationNextParam({
data: [1, 2, 3],
orderedBy: (x) => x,
limit: 5,
});
expect(result).toBeUndefined();
});
it('should encode the last item as next', () => {
const result = getPaginationNextParam({
data: [1, 2, 3],
orderedBy: (x) => x,
limit: 3,
});
const decoded = decodeAfterParamToText(result!);
expect(decoded).toEqual('3');
});
it('should set next as the stringified sort key if it is a number', () => {
const result = getPaginationNextParam({
data: [1, 2, 3],
orderedBy: (x) => x * 2,
limit: 3,
});
const decoded = decodeAfterParamToText(result!);
expect(decoded).toEqual('6');
});
it('should set next as the string sort key if it is a string', () => {
const result = getPaginationNextParam({
data: ['test'],
orderedBy: (x) => x,
limit: 1,
});
const decoded = decodeAfterParamToText(result!);
expect(decoded).toEqual('test');
});
it('should set next to the string sort key even when using non-latin characters', () => {
const result = getPaginationNextParam({
data: ['✅æ'],
orderedBy: (x) => x,
limit: 1,
});
const decoded = decodeAfterParamToText(result!);
expect(decoded).toEqual('✅æ');
});
it('should set next as the JSON-stringified sort key if it is an object', () => {
const result = getPaginationNextParam({
data: [1, 2, 3],
orderedBy: (x) => ({ id: x }),
limit: 3,
});
const decoded = JSON.parse(decodeAfterParamToText(result!));
expect(decoded).toEqual({ id: 3 });
});
});
export function decodeAfterParamToText(value: string) {
return Buffer.from(value, 'base64').toString('utf-8');
}
function encodeAfterParamFromText(value: string) {
return Buffer.from(value).toString('base64');
}
export type OrderedBy = string | number | Record<keyof unknown, unknown>;
/**
* Encodes an `after` parameter for use with pagination. Useful for testing that the `after` parameter is correctly encoded.
* If you're using this from your API handler, consider using {@link getPaginationNextParam} instead.
* @param orderedBy The ordered by values to encode, which should match the fields used in the `orderBy` function of your query.
*/
export function encodeAfterParam(orderedBy: OrderedBy) {
let textValue: string;
switch (typeof orderedBy) {
case 'string':
textValue = orderedBy;
break;
case 'number':
textValue = orderedBy.toString();
break;
default:
textValue = JSON.stringify(orderedBy);
break;
}
return encodeAfterParamFromText(textValue);
}
function getNextPageAfter<T>(values: T[], limit: number) {
const reachedEndOfResults = limit !== values.length;
if (reachedEndOfResults) {
return undefined;
}
return values.at(-1);
}
interface PaginationOptions<T> {
/**
* The data array which is being returned from the query. The last item in this array will be used to determine the next page.
*/
data: T[];
/**
* A function that extracts the sort keys from each entry in the data array. This should match the fields used in the `orderBy` function of your query. Note that this does not enforce the `orderedBy` on the array - you must have already ordered the data before calling this function.
*/
orderedBy: (entry: T) => OrderedBy;
/**
* The limit of items per page. This is used to determine if the end of the results has been reached. This should match the `limit` used in your query. Note that this does not enforce the `limit` on the array - you must have already limited the data before calling this function.
*/
limit: number;
}
/**
* Formats the response for client pagination.
* @returns The encoded after parameter for the next page, or undefined if there are no more pages. You should include this in the `next` field of your response.
* @example
* ```ts
* const data = await queryDatabase()
* .limit(25)
* .orderBy(asc(table.id));
*
* return {
* statusCode: 200,
* body: {
* data,
* next: encodeNextPageParam(data, (record) => record.id, 25),
* }
* }
* ```
*/
export function getPaginationNextParam<TInput>({
data,
orderedBy,
limit,
}: PaginationOptions<TInput>) {
const afterItem = getNextPageAfter(data, limit);
if (afterItem === undefined) {
return undefined;
}
const sortKeys = orderedBy(afterItem);
const next = encodeAfterParam(sortKeys);
return next;
}
import * as v from 'valibot';
import { vCoerceObject } from './valibotCoerce';
import { it, expect } from 'vitest';
it('fails when the input is not an object', () => {
const schema = vCoerceObject(v.object({ id: v.number() }));
const res = v.safeParse(schema, 'not an object');
expect(res.success).toEqual(false);
});
it('fails when the input is an empty object and a field is required', () => {
const schema = vCoerceObject(v.object({ id: v.number() }));
const res = v.safeParse(schema, {});
expect(res.success).toEqual(false);
});
it('passes when the input is an empty object and a field is optional', () => {
const schema = vCoerceObject(v.object({ id: v.optional(v.number()) }));
const res = v.parse(schema, {});
expect(res).toEqual({});
});
it('passes with the default value when the default is a function', () => {
const schema = vCoerceObject(
v.object({ id: v.optional(v.boolean(), () => true) })
);
const res = v.parse(schema, {});
expect(res).toEqual({ id: true });
});
it('passes with the default value when the default is a value', () => {
const schema = vCoerceObject(v.object({ id: v.optional(v.boolean(), true) }));
const res = v.parse(schema, {});
expect(res).toEqual({ id: true });
});
it('fails when the input is undefined, even if all fields are optional', () => {
const schema = vCoerceObject(v.object({ id: v.optional(v.number()) }));
const res = v.safeParse(schema, undefined);
expect(res.success).toEqual(false);
});
it('fails an empty string', () => {
const schema = vCoerceObject(v.object({ id: v.number() }));
const res = v.safeParse(schema, { id: '' });
expect(res.success).toEqual(false);
});
it('converts a string to a number', () => {
const schema = vCoerceObject(v.object({ id: v.number() }));
const res = v.parse(schema, { id: '1' });
expect(res).toEqual({ id: 1 });
});
it('fails when the string cannot be converted to a number', () => {
const schema = vCoerceObject(v.object({ id: v.number() }));
const res = v.safeParse(schema, { id: 'test' });
expect(res.success).toEqual(false);
});
it('preserves property pipes', () => {
const schema = vCoerceObject(
v.object({
id: v.pipe(
v.number(),
v.transform((n) => !!n)
),
})
);
const res = v.parse(schema, { id: '1' });
expect(res).toEqual({ id: true });
});
it('preserves object pipes', () => {
const schema = vCoerceObject(
v.pipe(
v.object({
original: v.string(),
}),
v.transform((obj) => 'Transformed: ' + obj.original)
)
);
const res = v.parse(schema, {
original: 'input',
});
expect(res).toEqual('Transformed: input');
});
it('preserves both property and object pipes', () => {
const schema = vCoerceObject(
v.pipe(
v.object({
id: v.pipe(
v.number(),
v.transform((v) => v * 2)
),
}),
// Adds a new field based on whether id is truthy
v.transform((v) => ({ ...v, hasId: !!v.id }))
)
);
const res = v.parse(schema, { id: '1' });
expect(res).toEqual({ id: 2, hasId: true });
});
it('passes a valid boolean string', () => {
const schema = vCoerceObject(v.object({ isActive: v.boolean() }));
const res = v.parse(schema, { isActive: 'true' });
expect(res).toEqual({ isActive: true });
});
it('fails an invalid boolean string', () => {
const schema = vCoerceObject(v.object({ isActive: v.boolean() }));
const res = v.safeParse(schema, { isActive: 'notabool' });
expect(res.success).toEqual(false);
});
it('coerces string props according to value types', () => {
const schema = vCoerceObject(
v.object({
id: v.number(),
isActive: v.boolean(),
name: v.string(),
})
);
const res = v.parse(schema, {
id: '1',
isActive: 'true',
name: 'Test User',
});
expect(res).toEqual({
id: 1,
isActive: true,
name: 'Test User',
});
});
it('fails validation accordingly when inputs are invalid for the nested types', () => {
const schema = vCoerceObject(
v.object({
id: v.number(),
isActive: v.boolean(),
name: v.string(),
})
);
const res = v.safeParse(schema, {
id: 'notanumber',
isActive: 'notabool',
name: 'Test User',
});
expect(res.success).toEqual(false);
});
import * as v from 'valibot';
/**
* We only support `string`, `number`, or `boolean` for coercion.
*
* If you want to support more types, you must update the {@link coerceValue} function
*/
type CoerceableTypes = string | number | boolean;
type CoerceableInnerSchema = v.GenericSchema<CoerceableTypes, unknown>;
type OptionalCoerceableSchema = v.OptionalSchema<
CoerceableInnerSchema,
v.Default<CoerceableInnerSchema, undefined>
>;
type CoerceableSchema = CoerceableInnerSchema | OptionalCoerceableSchema;
export type CoerceableObjectSchema = v.ObjectSchema<
Record<string, CoerceableSchema>,
v.ErrorMessage<v.ObjectIssue> | undefined
>;
/**
* Wraps an object schema to coerce inputs to the expected types. Useful for APIs where you expect inputs like route params or query string values, and need to convert them to types such as number or boolean.
* @example
* ```ts
* const schema = vCoerceObject(
* v.object({
* id: v.optional(v.number()),
* isActive: v.boolean(),
* description: v.string(),
* })
* );
*
* const res = v.parse(schema, { id: '1', isActive: 'true', description: 'A test' });
* console.log(res); // { id: 1, isActive: true, description: 'A test' }
* ```
*/
export const vCoerceObject = <
TSchema extends
| CoerceableObjectSchema
| v.SchemaWithPipe<
readonly [CoerceableObjectSchema, ...(readonly v.GenericPipeItem[])]
> = CoerceableObjectSchema,
>(
schema: TSchema
): v.GenericSchema<Record<string, string>, v.InferOutput<TSchema>> => {
const [objectSchema, ...pipeEntries] =
'pipe' in schema ? schema.pipe : [schema];
const coerceObjectSchema = v.object({
...Object.fromEntries(
Object.entries(objectSchema.entries).map(([key, fieldSchema]) => [
key,
getCoerceSchema(fieldSchema),
])
),
});
if (pipeEntries.length === 0) {
return coerceObjectSchema;
} else {
return v.pipe(coerceObjectSchema, ...pipeEntries);
}
};
/**
* Checks if a schema is an optional schema
* @see https://valibot.dev/api/OptionalSchema/
*/
const isOptionalSchema = (
schema: CoerceableSchema
): schema is OptionalCoerceableSchema => schema.type === 'optional';
/**
* Gets the necessary valibot schema that coerces a string input to the expected type, or allows it to be undefined if the schema is optional.
* @example
* ```ts
* const schema = getCoerceSchema(v.optional(v.number()));
* const res = v.parse(schema, '42'); // 42
* const res2 = v.parse(schema, undefined); // undefined
* ```
*/
const getCoerceSchema = <TSchema extends CoerceableSchema>(
schema: TSchema
): v.GenericSchema<string, v.InferOutput<TSchema>> => {
const requiredSchema = unwrapOptionalSchema(schema);
const schemaWithCoerce = v.pipe(
v.string(),
v.transform((val) => coerceValue(val, requiredSchema)),
requiredSchema
);
if (isOptionalSchema(schema)) {
return v.optional(
schemaWithCoerce,
getDefaultValue(schema)
) as v.GenericSchema<string, v.InferOutput<TSchema>>;
} else {
return schemaWithCoerce;
}
};
/**
* Gets the schema that's been wrapped by an optional schema
* @example
* ```ts
* const schema = v.optional(v.number());
* const unwrappedSchema = unwrapOptionalSchema(schema);
* console.log(unwrappedSchema.type); // 'number'
* ```
*/
const unwrapOptionalSchema = (schema: CoerceableSchema) =>
isOptionalSchema(schema) ? schema.wrapped : schema;
/**
* Gets the default value for a schema, converting it to a string if necessary.
* This is used to ensure that the default value is compatible with the coercion logic.
* @example
* ```ts
* const schema = v.optional(v.number(), 42);
* const defaultValue = getDefaultValue(schema);
* console.log(defaultValue); // '42'
* ```
*/
const getDefaultValue = (
schema: OptionalCoerceableSchema
): v.Default<
v.OptionalSchema<v.GenericSchema<string, unknown, v.StringIssue>, string>,
undefined
> => {
if (schema.default === undefined) {
return undefined;
}
if (typeof schema.default === 'function') {
const func = schema.default;
return (...args) => `${func(...(args as Parameters<typeof func>))}`;
} else {
return `${schema.default}`;
}
};
/**
* Coerces a string input to the expected type defined in the schema.
* @example
* ```ts
* const coercedValue = coerceValue('42', v.number()); // 42
* ```
* @see https://github.com/TanStack/router/blob/1f3468771396f2253c4781d15bc9b7e7c6d4228f/packages/router-core/src/qss.ts#L37-L54
*/
const coerceValue = (input: string, fieldSchema: CoerceableInnerSchema) => {
switch (fieldSchema.expects) {
case 'number':
if (+input * 0 !== 0) {
// Is not a valid number
return input;
} else if (`${+input}` !== input) {
// Is a valid number, but written in a way that means we should not convert it
// E.g. "1.0" or "1e3"
return input;
} else {
// Convert to a number
return +input;
}
case 'boolean':
if (input.toLowerCase() === 'true') {
return true;
} else if (input.toLowerCase() === 'false') {
return false;
} else {
return input;
}
default:
return input;
}
};
import crypto from 'node:crypto';
import * as v from 'valibot';
import { vAfter, vId, vTrimOptional, vTrimString } from './valibotUtils';
import { describe, it, expect, vi } from 'vitest';
import { encodeAfterParam } from './afterParam';
describe('vTrimString', () => {
it('fails undefined', () => {
const res = v.safeParse(vTrimString(), undefined);
expect(res.success).toEqual(false);
});
it('passes a string', () => {
const res = v.parse(vTrimString(), 'asdf');
expect(res).toEqual('asdf');
});
it('trims a string', () => {
const res = v.parse(vTrimString(), ' asdf ');
expect(res).toEqual('asdf');
});
it('fails an empty string', () => {
const res = v.safeParse(vTrimString(), '');
expect(res.success).toEqual(false);
});
it('fails a whitespace string', () => {
const res = v.safeParse(vTrimString(), ' ');
expect(res.success).toEqual(false);
});
it('fails an invalid url string', () => {
const res = v.safeParse(v.pipe(vTrimString(), v.url()), 'asdf');
expect(res.success).toEqual(false);
});
it('passes a valid untrimmed url string', () => {
const res = v.parse(
v.pipe(vTrimString(), v.url()),
' https://example.com '
);
expect(res).toEqual('https://example.com');
});
it('returns the error message when validation fails due to an incorrect type', () => {
const res = v.safeParse(vTrimString('Test error message'), 0);
expect(res.success).toEqual(false);
expect(res.issues![0].message).toEqual('Test error message');
});
it('returns the error message when validation fails due to being an empty string', () => {
const res = v.safeParse(vTrimString('Test error message'), '');
expect(res.success).toEqual(false);
expect(res.issues![0].message).toEqual('Test error message');
});
});
describe('vTrimOptional', () => {
it('allows a missing property on an object', () => {
const schema = v.object({ val: vTrimOptional() });
const res = v.parse(schema, {});
expect(res).toEqual({ val: undefined });
});
it('allows undefined', () => {
const schema = vTrimOptional();
const res = v.parse(schema, undefined);
expect(res).toEqual(undefined);
});
it('allows undefined, even when a schema is provided', () => {
const schema = vTrimOptional(v.pipe(v.string(), v.uuid()));
const res = v.parse(schema, undefined);
expect(res).toEqual(undefined);
});
it('does not execute inner schema when passed an empty string', () => {
const fn = vi.fn((data: string): string => data);
const res = v.parse(vTrimOptional(v.pipe(v.string(), v.transform(fn))), '');
expect(res).toEqual(undefined);
expect(fn).not.toHaveBeenCalled();
});
it('infers transformed types correctly', () => {
const schema = vTrimOptional(v.pipe(v.string(), v.transform(Number)));
const res = v.parse(schema, '1');
// Left as `=== 1` instead of `.toEqual(1)` because we're also checking that Typescript infers the type correctly
expect(res === 1).toEqual(true);
});
it('uses the default value when passed undefined', () => {
const schema = vTrimOptional(undefined, '1');
const res = v.parse(schema, undefined);
expect(res).toEqual('1');
});
it('uses the default value when passed a whitespace string', () => {
const schema = vTrimOptional(undefined, '1');
const res = v.parse(schema, ' ');
expect(res).toEqual('1');
});
it("converts '' to undefined", () => {
const schema = vTrimOptional();
const res = v.parse(schema, '');
expect(res).toEqual(undefined);
});
it('converts whitespace to undefined', () => {
const schema = vTrimOptional();
const res = v.parse(schema, ' ');
expect(res).toEqual(undefined);
});
it('trims whitespace', () => {
const schema = vTrimOptional();
const res = v.parse(schema, ' a ');
expect(res).toEqual('a');
});
it('runs schema against passed value after whitespace is trimmed', () => {
const uuid = crypto.randomUUID();
const schema = vTrimOptional(v.pipe(v.string(), v.uuid()));
const res = v.parse(schema, ` ${uuid} `);
expect(res).toEqual(uuid);
});
it('trims newlines', () => {
const schema = vTrimOptional();
const res = v.parse(schema, '\na\r\n');
expect(res).toEqual('a');
});
});
describe('vAfter', () => {
it('should return undefined when no value is passed', () => {
const schema = vAfter(v.object({ id: v.number() }));
const result = v.safeParse(schema, undefined);
expect(result.success).toBeTruthy();
expect(result.output).toBeUndefined();
});
it('should parse a valid encoded JSON string', () => {
const schema = vAfter(v.object({ id: v.number() }));
const encoded = encodeAfterParam({ id: 1 });
const result = v.safeParse(schema, encoded);
expect(result.success).toBeTruthy();
expect(result.output).toEqual({ id: 1 });
});
it('should infer types', () => {
const schema = vAfter(v.object({ id: v.number() }));
const encoded = encodeAfterParam({ id: 1 });
const result = v.parse(schema, encoded);
expect(result!.id).toEqual(1);
});
it('should return an error for non-JSON strings', () => {
const schema = vAfter(v.object({ id: v.number() }));
const result = v.safeParse(schema, encodeAfterParam('not-json'));
expect(result.success).toBeFalsy();
expect(result.issues![0].message).toBe(
'Invalid format. Must be base64 encoded JSON.'
);
});
it('should return an error for invalid JSON', () => {
const schema = vAfter(
v.object({ id: v.pipe(v.number(), v.transform(Number)) })
);
const encoded = encodeAfterParam({ id: 'not-a-number' });
const result = v.safeParse(schema, encoded);
expect(result.success).toBeFalsy();
expect(result.issues![0].message).toContain(
'Invalid type: Expected number but received "not-a-number"'
);
});
});
import * as v from 'valibot';
import { decodeAfterParamToText } from './afterParam';
/**
* Trims strings, and requires them to be non-empty (after being trimmed).
*/
export const vTrimString = <
const TMessage extends v.ErrorMessage<
v.StringIssue | v.NonEmptyIssue<string>
>,
>(
message?: TMessage
) => v.pipe(v.string(message), v.trim(), v.nonEmpty(message));
/**
* - If the value is undefined, it returns the default value.
* - If the value is a string, it trims it and returns it.
* - If the value is an empty string after trimming, it returns the default value.
* - If a schema is provided, it applies to the trimmed string.
*
* @example
* ```ts
* const schema = vTrimOptional();
* v.parse(schema, ' '); // undefined
* v.parse(schema, ' asdf '); // 'asdf'
* v.parse(schema, undefined); // undefined
*
* // With an inner schema
* const schemaWithPipe = vTrimOptional(v.pipe(v.string(), v.url()));
* v.parse(schemaWithPipe, ' http://example.com '); // 'http://example.com'
* v.parse(schemaWithPipe, undefined); // undefined
*/
export const vTrimOptional = <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TItem extends v.GenericSchema<string, any> = v.GenericSchema<string>,
>(
schema?: TItem,
default_?: string
) => {
let trimSchema: v.GenericSchema<string, v.InferOutput<TItem>> = v.pipe(
v.string(),
v.transform((input) => input.trim() || default_)
);
if (schema) {
trimSchema = v.pipe(trimSchema, v.optional(schema, default_));
}
return v.optional(trimSchema, default_);
};
/**
* Parses an `after` parameter from a base64 encoded JSON string. Useful for pagination.
* The parsed value is validated against the provided schema.
* @example
* ```ts
* after: vAfter(
* v.object({
* id: v.number(),
* lastName: v.string(),
* })
* )
* ```
*/
export const vAfter = <TSchema extends v.GenericSchema>(schema: TSchema) =>
v.optional(
v.pipe(
v.string(),
v.transform((v) => v.trim() || undefined),
v.optional(
v.pipe(
v.string(),
v.transform((input) => decodeAfterParamToText(input)),
v.rawTransform((ctx): v.InferOutput<TSchema> | undefined => {
try {
return JSON.parse(ctx.dataset.value) as unknown;
} catch {
ctx.addIssue({
message: 'Invalid format. Must be base64 encoded JSON.',
});
return ctx.NEVER;
}
}),
schema
)
)
)
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment