sdxc

Type to search, or start from one of these:

[ Language & values ]

@sdxc/bracket-params

Read and write nested query strings and form data with bracket syntax, validated by a Standard Schema

npm add @sdxc/bracket-params
pnpm add @sdxc/bracket-params
yarn add @sdxc/bracket-params
bun add @sdxc/bracket-params
Depends on
@standard-schema/spec

Read and write nested query strings and form data with bracket syntax, validated by a Standard Schema.

URLSearchParams and FormData map a name to flat values. This package reads bracket keys such as filter[status]=open and items[0][quantity]=2 into nested objects and arrays, runs them through your schema in the same call, and writes a nested value back in the same syntax.

Installation

npm add @sdxc/bracket-params

Schemas come from any Standard Schema library, such as remix's remix/data-schema, installed alongside.

Usage

Read A Query String

import { parse } from "@sdxc/bracket-params";
import * as s from "remix/data-schema";

parse("?tags[]=a&tags[]=b", s.object({ tags: s.array(s.string()) }));
// { status: "success", data: { tags: ["a", "b"] } }

Read A URL

import { parse } from "@sdxc/bracket-params";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";

let Filters = s.object({
	filter: s.object({ status: s.string(), tags: s.array(s.string()) }),
	page: coerce.number(),
});

// https://example.com/tasks?filter[status]=open&filter[tags][]=a&page=2
let result = parse(new URL(request.url), Filters);

if (isFailure(result)) return Response.json(result.error.issues, { status: 400 });
result.data; // { filter: { status: "open", tags: ["a"] }, page: 2 }

Read A Form

import { parse } from "@sdxc/bracket-params";
import * as s from "remix/data-schema";

let Post = s.object({
	post: s.object({ title: s.string(), photos: s.array(s.instanceof_(File)) }),
});

// post[title]=Hi, post[photos][]=<file>, post[photos][]=<file>
let result = parse(await request.formData(), Post);

Write A Query Or A Form

import { stringify, toFormData } from "@sdxc/bracket-params";

let query = stringify({ filter: { status: "open" }, page: 2 });
// "filter%5Bstatus%5D=open&page=2"

let form = toFormData({ post: { title: "Hi", photos: [file] } });
await fetch("/posts", { method: "POST", body: form });

API

parse(source, schema, options?)

Reads a query string (with or without its ?), URLSearchParams, URL, @sdxc/location Location or FormData into nested values and validates them with a synchronous schema, returning an @sdxc/result Result with the schema's output or an @sdxc/validate ValidationError whose issues carry each path.

SourceValue
a=1&a=2{ a: ["1", "2"] }
a[b][c]=1{ a: { b: { c: "1" } } }
a[]=1&a[]=2{ a: ["1", "2"] }
a[1]=y&a[0]=x{ a: ["x", "y"] }
a[0][b]=1{ a: [{ b: "1" }] }
a[0]=x&a[k]=y{ a: { 0: "x", k: "y" } }
a[b=1{ "a[b": "1" }

Text values stay strings, so types come from the schema (coerce.number()), and files arrive unchanged. A key used both as a value and as a group (a=1&a[b]=2) fails the parse, and a key with a __proto__ segment is ignored.

  • options.depth: bracket segments one key may nest before the parse fails. Default 5.

  • options.parameterLimit: entries one source may carry before the parse fails. Default 1000.

stringify(value)

Writes an object as a query string without its ?, with objects as key[child], arrays as key[index] and a Date as its ISO string, skipping null and undefined. It stands in for appending every bracket key by hand:

let params = new URLSearchParams();
params.append("filter[status]", "open");
params.append("page", "2");
params.toString(); // what stringify({ filter: { status: "open" }, page: 2 }) returns

toFormData(value)

Writes an object as FormData with the keys stringify writes, appending each Blob or File as its own field so it keeps its name and type.

fieldName(path)

Writes a path as its bracket field name, so fieldName(["items", 0, "quantity"]) is "items[0][quantity]". It takes a schema issue's path as-is.

Types

  • BracketParamsSource: the sources parse reads.

  • ParseOptions: the depth and parameterLimit options of parse.

  • TextValue: a value the writers write as text: string, number, boolean, bigint, Date, null or undefined.

  • FormValue: a TextValue or a Blob, which only toFormData accepts.

  • BracketInput<Value, Leaf>: the nested shape of objects and arrays the writers accept.

  • PathSegment: one segment of a fieldName path, a key or a schema issue's { key }.

Read the current filters, replace one, and write the link back. Every other part of the query carries over.

import { parse, stringify } from "@sdxc/bracket-params";
import { unwrap } from "@sdxc/result";
import * as s from "remix/data-schema";

let Query = s.object({
	q: s.defaulted(s.string(), ""),
	filter: s.defaulted(s.object({ status: s.optional(s.string()) }), {}),
});

let current = unwrap(parse(new URL(request.url), Query));
let openHref = `?${stringify({ ...current, filter: { ...current.filter, status: "open" } })}`;

Pattern: Show Each Issue On Its Input

A form names its inputs with fieldName, and the same function turns each issue's path back into that name, so a refused submission can mark the exact input that failed.

import { fieldName, parse } from "@sdxc/bracket-params";
import * as s from "remix/data-schema";
import { min } from "remix/data-schema/checks";
import * as coerce from "remix/data-schema/coerce";

let Order = s.object({
	items: s.array(s.object({ sku: s.string(), quantity: coerce.number().pipe(min(1)) })),
});

// <input name={fieldName(["items", 0, "quantity"])} /> submits items[0][quantity]
let result = parse(await request.formData(), Order);

if (result.status === "failure") {
	let errors = new Map(
		result.error.issues.map((issue) => [fieldName(issue.path ?? []), issue.message]),
	);
	errors.get("items[0][quantity]"); // "Expected number greater than or equal to 1"
}