← Concepts & practices
Concept Data modeling and type design

Parse, don’t validate

Keep what the check found out.

You already parse. Every time you read ?page=2 from a URL, a form field, or an environment variable, text becomes something your code trusts. Let’s follow a job board’s search parameters from a yes/no check to a parser that hands the rest of the code values it can use.

TypeScriptGo One search handler, two implementations.

01 / The idea

A yes/no check is a fair first answer.

You’re building the search page for a job board. The URL carries three settings: sort, page, and remote. They arrive as strings, and any of them can be missing. A first version checks them and answers yes or no, which is enough to choose between results and an error.

Read the checkTypeScript · the version this lesson starts from
query.ts
// The first version answers yes or no, using the same rules as the parser below.
export function isValidSearch(params: URLSearchParams): boolean {
	return parseSearch(params).ok;
}

// So every caller still holds strings, and turns them into values again.
export function listingFromStrings(params: URLSearchParams): Listing {
	return {
		orderBy: params.get('sort') === 'salary' ? 'salary' : 'posted_at',
		remoteOnly: params.get('remote') === 'true',
		offset: (Number(params.get('page') ?? '1') - 1) * pageSize,
		limit: pageSize
	};
}

It uses the same rules as the parser in section 02, so both accept exactly the same URLs. Go has the same check. Both languages meet again at the parser.

Now look at what the handler has after a yes. Still strings: “salary”, “08”, “true”. It converts the page to a number and picks a default, and so does the pager, the results header, and the next endpoint that reads the same URL. Each copy is a second opinion on what the URL means.

Parsing checks the input and returns what the check found out, as values the rest of the code can use. Validation still happens. What changes is what comes back: not true, but a Search with a real page number, the defaults filled in, and a type that says the check already ran. The name comes from Alexis King’s post Parse, don’t validate: “a parser is just a function that consumes less-structured input and produces more-structured output.”

If you write UI code, you’ve read new URLSearchParams(location.search).get('page') in more than one component. Each of those is a small parser with its own default. Section 05 moves that work to one place, and handles the link someone pasted with page=abc in it.

02 / See the shape

Check the whole value, then return it.

The basic form parses one field: a page is 1–4 digits and at least 1, and only then does it become a Page. In the wild parses the whole query, with defaults for absent fields. At the call site hands the result to the code that builds the database query.

Both languages run the same 23 URLs and give the same response to every one. Each says it in its own way.

One field, parsed. A page is 1–4 digits and at least 1, and only after both checks does it become a Page.

TypeScriptReading
query.ts
declare const pageBrand: unique symbol;
export type Page = number & { readonly [pageBrand]: 'Page' };
export type Sort = 'newest' | 'salary';
export type Search = Readonly<{ sort: Sort; page: Page; remote: boolean }>;
export type Field = 'sort' | 'page' | 'remote';
export type Parsed<T> = { ok: true; value: T } | { ok: false; field: Field; message: string };

export function parsePage(text: string): Parsed<Page> {
	if (!/^[0-9]{1,4}$/.test(text)) {
		return { ok: false, field: 'page', message: 'Use 1–4 digits.' };
	}
	const value = Number(text);
	if (value < 1) {
		return { ok: false, field: 'page', message: 'Pages start at 1.' };
	}
	// The assertion records what the two checks above established. It checks nothing itself.
	return { ok: true, value: value as Page };
}
GoAlongside
query.go
// Page's field is unexported, so other packages can't set it; outside ParsePage they get only the zero Page.
type Page struct{ n int }

type Search struct {
	sort   string
	page   Page
	remote bool
}

type ParseError struct{ Field, Message string }

func (e *ParseError) Error() string { return e.Field + ": " + e.Message }

func ParsePage(text string) (Page, error) {
	if len(text) < 1 || len(text) > 4 {
		return Page{}, &ParseError{"page", "Use 1–4 digits."}
	}
	n := 0
	for _, c := range []byte(text) {
		if c < '0' || c > '9' {
			return Page{}, &ParseError{"page", "Use 1–4 digits."}
		}
		n = n*10 + int(c-'0')
	}
	if n < 1 {
		return Page{}, &ParseError{"page", "Pages start at 1."}
	}
	return Page{n: n}, nil
}
Reading the TypeScriptA brand, an assertion, and URLSearchParams

Page is a number with a brand: a tag that exists only in the type, so a plain number isn’t assignable to it. The only as Page is in parsePage, after the checks. That assertion records what the checks established. It doesn’t check anything itself.

URLSearchParams.get returns the first value of a repeated key and null for an absent one, so ?page= (present and empty) stays different from no page at all. The result is frozen, and every field is a primitive, so a shallow freeze covers it.

Reading the GoUnexported fields, Has, and zero values

Page and Search keep their fields unexported, so other packages can’t fill them in: a real Search comes only from ParseSearch. Code in the same package can still build one by hand, which is why a real app puts them in a package of their own. Any package can write the zero value, though.

Values.Get returns an empty string for an absent key, so lookup uses Has to tell absent from empty. url.ParseQuery is also stricter than URLSearchParams: it rejects bad escapes like %zz and semicolons before the parser runs.

A zero Search compiles without ever meeting the parser. ListingFor checks for it and returns an error instead of querying page 0.

03 / Follow the URL

Watch the same URL get checked, then parsed.

Five steps, each running the TypeScript you just read. The compiler message in the last step is the real one, pinned by the lesson’s tests. Before each step, guess what the handler gets to work with.

In Try it, type your own query string, and swap in the forgiving parser from section 04.

Parse, don’t validate

One URL, checked and then parsed.

URL/jobs?sort=salary&page=08&remote=true

CheckisValidSearch

Query ?sort=salary&page=08&remote=true. isValidSearch returns true, and the caller still holds sort “salary”, page “08”, remote “true”.

01/ 05
Check a query string with isValidSearch

The check says yes.

isValidSearch returns true. The handler still holds “salary”, “08”, and “true”, and has to turn them into values again.

Reduced motion: choose a scene to see its completed state.

Read this scene

isValidSearch returns true. The handler still holds “salary”, “08”, and “true”, and has to turn them into values again.

Query ?sort=salary&page=08&remote=true. isValidSearch returns true, and the caller still holds sort “salary”, page “08”, remote “true”.

Watch restarts when you return. Step through keeps your selected step. Try it starts from the first query each time you open it.

What parsing buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Checked once, at the edge
parseSearch runs where the request arrives. Nothing past handleJobs looks at a string again.
Consumers can’t take raw input
listing takes a Search. Hand it a plain 8 and TypeScript refuses, with the message from step 5.
Defaults live in one place
An absent page becomes 1 inside the parser, not separately in every component that reads the URL.
Errors name the field
“page: Use 1–4 digits.” The yes/no check had nothing to add to false.
The check and the use can’t drift
isValidSearch and listingFromStrings are two readings of one URL. The parser is a single reading, so there’s nothing to disagree with.

The opposite has a name too. King’s post quotes it as shotgun parsing: checks “mixed with and spread across processing code”, hoping one of them catches the bad cases. Section 08 covers what parsing still leaves to you.

04 / Try a decision

A forgiving parser accepts a page that doesn’t exist.

Support forwards a complaint: someone’s bookmark has page=2abc in it and shows an error. A teammate makes the page forgiving, Number.parseInt(raw ?? '1', 10) || 1, still cast to Page. The bookmark works again.

With the forgiving parser, what does /jobs?page=-3 return?

The page is now (Number.parseInt(raw ?? '1', 10) || 1) as Page. The same change made sort and remote forgiving too: an unknown value falls back to the default instead of a 400.

05 / Give it a real job

Parse where the request arrives. Pass the result inward.

In the real app, the /jobs API handler parses the query before it does anything else. A bad value becomes a 400 that names the field. A good one becomes a Search, and from there the code that builds the database query, the result count, and the pagination links all take that Search.

Request boundary

Parses once

Strings in; a Search or a field error out.

Query builder

Uses the values

Order, filter, offset, and limit.

Response

Reports either way

A 400 that names the field, or a 200 with rows.

The web page reads the same URL, so it uses the same parser. What it does with a failure is a separate decision. An API can answer 400; a page someone opened from a link can drop the bad field and show real results.

The example leaves out the database, keyword search, and collecting every error for a form with many fields. None of those change where parsing happens.

Build UIs?Every component that reads the URL is a small parser, and one day a pasted link with page=abc makes you decide what a bad value means.

Where it already is in your components

Filters, tabs, and pagination live in the URL so links can be shared. So components read searchParams.get('page') and write Number(…) with a default, often in three places. Each is a parser without the name, and they don’t always agree on what an empty or bad value means.

Parsing once at the top of the page gives every child a Search instead of a string. In Svelte, a $derived parse reruns whenever the query changes, and the {#if} on result.ok narrows to the parsed value.

When you have to own it

Now it’s the filters panel. The URL changes three ways: someone clicks a filter, someone presses Back, or someone pastes a link a colleague edited by hand. And the “Go to page” box holds a draft that’s allowed to be half typed.

So the address bar is the boundary. Parse it on load and on every Back. If a pasted link has a bad field, drop that field, replace the URL so the bad link stops spreading, and say what happened. The page box parses its draft only when it’s submitted, with the same parsePage, and shows the message beside the field.

That’s the lesson in one component: one parser for the URL and the form, defaults in one place, and a visible decision about bad input instead of a quiet || 1.

filters.ts
import { parseSearch, type Search } from '../search/query';

// A pasted link can carry a bad value. Drop each field that fails, one at a time, and
// report which ones, so the page shows real results and the address bar stops lying.
// Each pass removes a field, and an absent field always parses, so this ends.
export function repairSearch(query: string): { search: Search; dropped: string[] } {
	const params = new URLSearchParams(query);
	const dropped: string[] = [];
	for (;;) {
		const result = parseSearch(params);
		if (result.ok) return { search: result.value, dropped };
		params.delete(result.field);
		dropped.push(result.field);
	}
}

A jobs page that parses the query once, at the top. Children get a Search. React checks result.ok before rendering; Svelte’s $derived parse reruns when the query changes.

ReactAlready in your code
JobsPage.tsx
import { pageSize, parseSearch, type Search } from '../search/query';

// The version you've probably written: read the URL wherever it's needed.
//   const page = Number(new URLSearchParams(location.search).get('page') ?? 1);
// ...and again in the pager, the header, and the fetch, each with its own default.

export function JobsPage({ query }: { query: string }) {
	// Parse once, at the top. Everything below takes the parsed search.
	const result = parseSearch(new URLSearchParams(query));
	if (!result.ok) {
		return (
			<p role="alert">
				This link has an invalid {result.field}: {result.message}
			</p>
		);
	}
	return (
		<>
			<ResultsHeader search={result.value} />
			<Rows search={result.value} />
		</>
	);
}

function ResultsHeader({ search }: { search: Search }) {
	return (
		<h1>
			{search.remote ? 'Remote jobs' : 'All jobs'},{' '}
			{search.sort === 'salary' ? 'highest salary first' : 'newest first'}
		</h1>
	);
}

function Rows({ search }: { search: Search }) {
	// page is already a number from 1 to 9999. No Number(), no default, no NaN.
	const first = (search.page - 1) * pageSize + 1;
	return (
		<p>
			Showing {first}–{first + pageSize - 1}
		</p>
	);
}

06 / Recognize it elsewhere

Anywhere text becomes something your code trusts.

You’ve written all of these. Each one is a parse, or a check that wishes it were.

Familiar raw input and what it is parsed into
Where you’ve seen itWhat arrivesWhat the code needs
A form submissionFormData stringsAn order with a quantity and a delivery date.
Environment variablesprocess.env.PORT, os.Getenv("PORT")A config with a port number, or a startup error.
An API responseunknown from fetchA typed payload, or an error you can show.
A command lineos.ArgsTyped flags with defaults.

The raw side is always less specific than what the code needs next. Parsing closes that gap once, where the input arrives.

07 / Already in your toolbox

The tools you use already return parsed values.

Three places to look. For each, find what comes in and what comes back.

Zod · safeParse

Returns the parsed data or an error, as a result you check before using. The docs call it a discriminated union: the success branch has data with the schema’s type, and the failure branch has the issues.

Read the parsing basics ↗

Go · flag

flag.Int turns a command-line argument into an int with a default. Run the program with -port=abc and it prints invalid value "abc" for flag -port: parse error, then the usage, and exits.

Look at the flag package ↗

Alexis King · Parse, don’t validate

The post that named the idea. It’s written with Haskell examples, and its point travels: a check that throws away what it learned leaves the next function to find it out again.

Read the original post ↗
A useful counterexample: a type guardWhen the shape is already right

A TypeScript type predicate like isJob(value): value is Job checks a value and narrows its type without building anything new. When decoded JSON already has the right shape, that’s enough: the check and the type travel together.

It isn’t enough when the value has to change. "08" needs to become 8, and an absent field needs a default. A guard can only say the string is fine; a parser returns the number.

08 / The parts to watch

A parser moves the checks to one place. It doesn’t decide everything.

These are the decisions that are still yours.

It stops at the first error

sort=oldest&page=0 reports sort and never mentions page. An API can live with that. A form with five fields usually wants every error at once, which needs a list of field errors instead of one.

The type can be talked around

8 as Page compiles, and so did the forgiving parser in section 04. In Go, code in the same package can build a Search by hand, and anyone can make a zero one. The brand is only as honest as the code that creates it, so keep that code in one place.

A parsed value belongs to one URL

When the address changes, through Back or a new filter, parse the new URL. Holding on to an old Search is how a page shows results for filters the address bar no longer has.

Valid isn’t the same as available

page=9999 parses and may return no rows. That’s an empty result, handled where the query runs, not a parse error.

Strict or forgiving is a product decision

People edit URLs by hand. You might answer 400, or drop the bad field and show the defaults. Decide once, at the boundary, and make it visible, the way section 05’s filters panel does. A quiet || 1 deep inside the parser is the one option that decides nothing.

Go’s query parser is stricter than URLSearchParams

url.ParseQuery rejects bad escapes like %zz and semicolons; URLSearchParams accepts both. The shared cases avoid them, and the Go tests pin the difference.

09 / Make the call

What would you have to change tomorrow?

Give both designs a plausible change and follow the work it creates.

How a change affects a yes/no check and a parser
The changeA yes/no checkA parser
A new consumer needs the pageIt reads the string and converts it again, with its own default.It takes search.page.
Pages now stop at 500Change the check, then find every converter that should agree.Change parsePage. Every caller gets it.
A form should list every bad fieldFits, if the check returns every message.Needs a list of field errors, not the first one.
One branch only asks “is sort=salary?”A local check is plenty.A whole Search is more than it needs.

Parse when more than one piece of code needs what the check found out. The handler, the pager, and the query builder all needing the page is the moment.

Keep a plain check when one branch needs one answer. Not every if needs a type.

The question I’d leave beside the code is: what does the next function receive, the strings or what the check learned?

10 / Take the idea with you

Explain the handler without saying “parse, don’t validate.”

“The handler turns the query string into a Search once, and everything after it takes that Search.” In a review, the words are parse at the boundary for where it happens, and shotgun parsing for the checks it replaces.

Before moving on, jot down what isValidSearch threw away, why page=-3 got through the forgiving parser, and one place in your own code that reads the same URL parameter twice.

Connections to follow nextRelated lessons
  • Discriminated unions shape the result: Parsed is one case for success and one for failure.
  • Branded and opaque types go further with Page: keeping checked values from being mixed up with unchecked ones.
  • Value objects give a parsed value rules and behavior of its own.
  • Factory controls how a value is created. A parser is a creation function whose input is less trustworthy.
  • Validation at the edge asks where in a system those boundaries should sit.

Take the parser into your editor. Add a minSalary parameter, decide its default and its error, and check which callers had to change. The answer should be none.

Back to Concepts & practices →