Validation and Domain Types

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.

Domain Types as the Backend’s Source of Truth

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.

  • Currency is a narrow type instead of a plain string, 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.
DTOs for Inputs We Accept From Clients

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 string and number, not “raw strings” like you get from the URL.
  • All fields are optional because query params are optional. A request like /api/products should be valid even when no params are present.
  • page and pageSize are 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.
Validation Utilities for Query Params

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 from URLSearchParams, 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:

  • isString checks 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.
Parsing Optional String Params

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.

Parsing Optional Integer Params

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 check trimmed.length === 0 and 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.
Validating GET /api/products with Shared Parsers

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.searchParams and immediately validates every supported param. This is intentional: we want to reject bad inputs before doing any expensive work.
  • query is treated as an optional string with constraints { min: 1, max: 64 }. If the param is missing, it becomes undefined. If it’s present but empty/too long after trimming, it fails validation.
Testing Validation Quickly in the Playground

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, and pageSize as 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.
  • URLSearchParams ensures proper encoding and avoids manual string concatenation bugs.
  • Because the backend trims and validates, this UI makes it easy to test cases like " " for query or "abc" for page.
Recap

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-aligned Product shape with price_cents, inventory_count, and a strict 'active' | 'archived' status union.
  • You defined a DTO input shape in src/lib/types/dto.ts for the query params accepted by GET /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.ts so invalid query params return consistent VALIDATION_ERROR responses with a 400 status.

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.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal