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
// 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.
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 };
} // 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.
One URL, checked and then parsed.
URL/jobs?sort=salary&page=08&remote=true
isValidSearchQuery ?sort=salary&page=08&remote=true. isValidSearch returns true, and the caller still holds sort “salary”, page “08”, remote “true”.
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
parseSearchruns where the request arrives. Nothing pasthandleJobslooks at a string again.- Consumers can’t take raw input
listingtakes aSearch. Hand it a plain8and 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
isValidSearchandlistingFromStringsare 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.
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.
Parses once
Strings in; a Search or a field error out.
Uses the values
Order, filter, offset, and limit.
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.
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.
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.
| Where you’ve seen it | What arrives | What the code needs |
|---|---|---|
| A form submission | FormData strings | An order with a quantity and a delivery date. |
| Environment variables | process.env.PORT, os.Getenv("PORT") | A config with a port number, or a startup error. |
| An API response | unknown from fetch | A typed payload, or an error you can show. |
| A command line | os.Args | Typed 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.
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.
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.
| The change | A yes/no check | A parser |
|---|---|---|
| A new consumer needs the page | It reads the string and converts it again, with its own default. | It takes search.page. |
| Pages now stop at 500 | Change the check, then find every converter that should agree. | Change parsePage. Every caller gets it. |
| A form should list every bad field | Fits, 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:
Parsedis 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.