Welcome back 👋 In the previous lesson, you established the project’s shared API contract (ApiSuccess, ApiError, ApiResponse) and the response helpers (success, error, parseJson) that make every route return consistent JSON envelopes. That foundation matters here because validation failures should also come back in the exact same structured error shape—just with a VALIDATION_ERROR code.
In this lesson, you’ll make the backend’s data shapes more explicit by introducing domain types and a small DTO for product listing inputs. Then you’ll build a tiny validation toolkit for query params and wire it into GET /api/products, so invalid inputs are rejected early with clean, consistent error responses.
A backend needs a canonical “truth” for what its entities look like internally—especially once the database is involved. In this project, those canonical shapes live in src/lib/types/domain.ts. These types are designed to match the columns we’ll read from PostgreSQL later, so we don’t end up inventing new shapes at every layer.
This file defines a Currency type (kept intentionally narrow) and a Product interface that includes pricing, inventory, and status fields you’ll see in real queries and API responses.
Currencyis a narrow type instead of a plainstring, which makes the system more explicit and prevents “random” currency codes from sneaking in.- For this course, we only support
'USD', which keeps the domain model simple while still demonstrating the pattern of constrained values. - Even if we expand currency support later, starting narrow is helpful because it forces consistency across the codebase.
Now here’s the canonical Product shape.
- This interface is intentionally “database-shaped.” Fields like
price_cents,inventory_count, and timestamps are exactly the kinds of columns we’ll read from PostgreSQL rows. - is an integer rather than a floating dollar amount. This avoids rounding problems that can happen when using floating point math for money.
Domain types describe what the backend knows internally. DTOs (Data Transfer Objects) describe what the client is allowed to send—or, in this case, what query params the client is allowed to provide.
In this unit, we’re focusing on GET /api/products, so our “input” is the query string. That input shape lives in src/lib/types/dto.ts as ListProductsInput.
- This interface represents the validated shape of the query params after parsing. Notice the types are
stringandnumber, not “raw strings” like you get from the URL. - All fields are optional because query params are optional. A request like
/api/productsshould be valid even when no params are present. pageandpageSizeare numbers here because pagination logic becomes much safer once you’ve converted and validated them early.- Even though the route in Unit 2 still returns an empty list, establishing this DTO shape now gives you a clean target: parse raw params → validate → produce a typed input.
Query parameters always start as strings (or missing entirely). If you let those raw values flow into your route logic, you’ll quickly end up with messy checks scattered everywhere. Instead, this project centralizes query parsing and validation in src/lib/http/validation.ts.
In this unit, the focus is on three responsibilities:
- Type guards (
isString,isInt) that check types and constraints. - Parsers (
parseOptionalStringParam,parseOptionalIntParam) that read fromURLSearchParams, trim/convert, and return a consistent result type.
First, the shared result shape used by the parsers:
- This is a simple “result object” pattern: either
{ ok: true, value }or{ ok: false, message }. - It avoids throwing errors for validation failures, which keeps route handlers clean and predictable.
- The route can decide how to convert a validation failure into an API error envelope (and what HTTP status to use).
Now the isString type guard:
isStringchecks both and . It’s not enough to know something is a string—you often also need to know it isn’t empty, or that it isn’t absurdly long.
Type guards are useful, but route handlers shouldn’t be responsible for trimming strings, dealing with missing params, and crafting error messages over and over. That’s where parsers come in.
parseOptionalStringParam reads a key from URLSearchParams, handles the “missing” case, trims the value, validates it, and returns a ValidationResult.
- Missing params are not errors. If the key isn’t present, the function returns
{ ok: true, value: undefined }, which keeps optional params truly optional. - Present params are trimmed. This avoids treating
" "as a meaningful value and helps prevent accidental whitespace from affecting behavior. - The length checks enforce constraints with clear, consistent error messages that mention the exact key name.
- The return type is
string | undefined, which matches how we want to treat optional query params in route logic: “either a valid string or not provided at all.”
This is especially important for query: a missing query should become undefined, not an empty string.
Pagination params need slightly different handling: we must reject empty strings, reject non-integers, and enforce numeric bounds.
parseOptionalIntParam follows the same “optional param” pattern as the string parser, but includes conversion and more numeric-specific validation.
- Like the string parser, missing params return
{ ok: true, value: undefined }. This keeps pagination optional unless the API requires it. - An empty-but-present param (like
?page=) is treated as an error. That’s why we explicitly checktrimmed.length === 0and return a helpful message. - The conversion uses
Number(trimmed)and then validates both finiteness and integer-ness. This ensures we reject values like"1.5","abc", or"Infinity". - Bounds checks (
min/max) produce clear messages that explain exactly what constraint was violated.
Now we apply the utilities in a real route. The products route lives at src/app/api/products/route.ts, and it already imports parseOptionalStringParam and parseOptionalIntParam. Your job is to use them consistently so the route validates inputs and returns standard error envelopes on invalid values.
Here’s the full GET handler as it exists in the project.
- The route reads
req.nextUrl.searchParamsand immediately validates every supported param. This is intentional: we want to reject bad inputs before doing any expensive work. queryis treated as an optional string with constraints{ min: 1, max: 64 }. If the param is missing, it becomesundefined. If it’s present but empty/too long after trimming, it fails validation.
The Playground at src/app/playground/page.tsx is a simple client-side tool to hit your API routes and display the JSON response. In Unit 2, it builds a /api/products URL from the current input fields so you can quickly try valid and invalid combinations.
This section is important because it mirrors a real-world workflow: you change an input, hit a route, and confirm that validation produces predictable errors.
- The UI treats
query,page, andpageSizeas strings because that’s how HTML inputs work—and that matches how query params arrive at the backend. - It only sets params when the input length is greater than
0. This lets you simulate “param missing” vs “param present” by clearing the field entirely. URLSearchParamsensures proper encoding and avoids manual string concatenation bugs.- Because the backend trims and validates, this UI makes it easy to test cases like
" "forqueryor"abc"forpage.
In this lesson, you made the backend more explicit and safer by separating “what the backend knows” from “what the client can send,” and by validating input at the boundary:
- You defined domain types in
src/lib/types/domain.ts, including a database-alignedProductshape withprice_cents,inventory_count, and a strict'active' | 'archived'status union. - You defined a DTO input shape in
src/lib/types/dto.tsfor the query params accepted byGET /api/products. - You implemented reusable validation building blocks in
src/lib/http/validation.ts, including type guards and parsers that trim, convert, and return clear validation results. - You wired those parsers into
src/app/api/products/route.tsso invalid query params return consistentVALIDATION_ERRORresponses with a400status.
Next, these same patterns will carry forward naturally: validate at the edges, convert to typed inputs early, and keep your route logic focused on the actual business behavior.
