
Where to Validate in a Real Application
Check a value where trust changes. After it is accepted, ordinary TypeScript handles it until the next boundary.
Where trust changes
- External value
- unknown
- validate
- Trusted value
- ordinary TypeScript
Boundaries at a glance
- 01: Browser storagePersisted text is still runtime data when it is read back.stored text → decode → validate → application state
- 02: URL and search parametersA query value stays string | null until a guard accepts it.string | null → narrow → domain value
- 03: API and SDK responsesValidate the response or field that is still uncertain.external response → uncertain data → validate what matters
- 04: Server and decoded payloadsA request body or decoded JSON text enters as unknown.request / decoded payload → unknown → application value
Design principles explain why these checks stay small. Best practices cover how to organize them. Validating unknown values shows how a check works.
01 / Browser storage
localStorage and sessionStorage return a string or null. Reading that string back does not restore the type written earlier. Missing, stale, edited, or non-JSON text is recoverable, so this reader returns an empty list. safeJsonParse decodes the text and applies the guard together.
Stored drafts
- localStorage
- string | null
- JSON decode
- unknown
- isDraftList
- Draft[]
import {arrayOf,isString,optionalKey,safeJsonParse,struct,} from 'is-kit';const isDraft = struct({title: isString,note: optionalKey(isString),});const isDraftList = arrayOf(isDraft);function readDrafts(storage: Storage, key: string) {try {const raw = storage.getItem(key);if (raw === null) return [];const parsed = safeJsonParse(raw, isDraftList);return parsed.valid ? parsed.value : [];} catch {// Storage can throw when it is unavailable.return [];}}
readDrafts returns that list for ordinary TypeScript. Read the key again after another write. Keep the guard next to the reader, and include only the fields that the reader still uses.
02 / URL and search parameters
URLSearchParams#get returns string | null. Some routers coerce numeric-looking query values before your code sees them, so read the raw query string when the exact text matters.
Search query
- URLSearchParams
- string | null
- guard
- view / token / page
import {and,isSafeInteger,isString,oneOfValues,predicateToRefine,} from 'is-kit';const isView = oneOfValues('list', 'board');const isNonEmptyString = and(isString,predicateToRefine<string>((value) => value.trim().length > 0),);const isPositiveIntString = and(isString,predicateToRefine<string>((value) => /^[1-9]\d*$/.test(value)),);function readPage(value: string | null): number {if (!isPositiveIntString(value)) return 1;// The text matched an integer pattern. Number only converts it.const page = Number(value);return isSafeInteger(page) ? page : 1;}function readQuery(params: URLSearchParams) {const viewParam = params.get('view');const token = params.get('token');return {view: viewParam !== null && isView(viewParam) ? viewParam : 'list',token: isNonEmptyString(token) ? token : undefined,page: readPage(params.get('page')),};}
An unknown view becomes 'list'. A missing or blank token stays absent. page continues from accepted integer text through Number to a safe integer.Number only converts text the guard has already accepted, and missing or non-integer text uses 1. The returned object is the trusted query. Casting get('token') with as string hides a missing token from both the type and the runtime.
03 / API and SDK responses
response.json() is data from another process. Assign it to unknown and accept the fields this call uses. The status comes from response.ok; the shape comes from the guard.
Response body
- response.json()
- unknown
- validate once
- trusted items
import { arrayOf, isString, oneOfValues, safeParse, struct } from 'is-kit';const isItem = struct({id: isString,name: isString,status: oneOfValues('open', 'closed'),});async function readItems(response: Response) {if (!response.ok) {throw new Error('Request failed');}const payload: unknown = await response.json();const parsed = safeParse(arrayOf(isItem), payload);if (!parsed.valid) {throw new Error('Invalid items response');}return parsed.value;}
readItems returns the accepted array. Callers filter and render it with ordinary TypeScript, and call readItems again when they need a new response.
Validate here
- A raw
fetch()response - Runtime-uncertain nested data
- Third-party data the application has not accepted yet
Usually do not validate again
- A value already accepted at a boundary
- Ordinary internal application state
- Every field on every later use
- A generated-client result the application has chosen to treat as its trust boundary
A generated TypeScript client does not check the response at runtime. Its types are erased at build time. If the application already treats that client as the trust boundary for a call, later code uses the resulting fields directly.
Check a field when that field is still uncertain, such as a nested payload from another service. Fall back for that field and leave the surrounding values on the type the application already uses.
import {arrayOf,isString,nullable,optionalKey,safeParse,struct,} from 'is-kit';type SettingsResponse = {companyName: string;// This field is still uncertain, so the reader checks it below.plans: unknown;};const isPlans = nullable(arrayOf(struct({name: optionalKey(nullable(isString)),}),),);function readPlans(settings: SettingsResponse) {const parsed = safeParse(isPlans, settings.plans);if (!parsed.valid || parsed.value === null) return [];return parsed.value;}
companyName stays a trusted field. plans is the uncertain boundary, so a mismatch becomes an empty list. When a selected payload should stay aligned with a generated object type, use typedStruct.
04 / Server and decoded payloads
A Route Handler and a decoded payload are the same boundary from the other side of the application. Start from the JSON value your code actually received.
Request or decoded JSON
- request or decoded JSON
- unknown
- guard
- application value
Request body
Invalid JSON fails at request.json() before the guard runs. After parsed.valid, message is the trusted command.
import { isString, safeParse, struct } from 'is-kit';const isCreateMessage = struct({title: isString,body: isString,});export async function POST(request: Request) {let payload: unknown;try {payload = await request.json();} catch {return Response.json({ error: 'Invalid request' }, { status: 400 });}const parsed = safeParse(isCreateMessage, payload);if (!parsed.valid) {return Response.json({ error: 'Invalid request' }, { status: 400 });}const message = parsed.value;return Response.json({ title: message.title }, { status: 201 });}
Decoded JSON
payloadJson is JSON text after any token-specific decoding has already happened. safeJsonParse decodes that text and checks the claims. Reading sub and exp does not verify a signature.
import { isNumber, isString, optionalKey, safeJsonParse, struct } from 'is-kit';const isSessionClaims = struct({sub: optionalKey(isString),exp: optionalKey(isNumber),});function readClaims(payloadJson: string) {const parsed = safeJsonParse(payloadJson, isSessionClaims);return parsed.valid ? parsed.value : undefined;}
Do I need a guard here?
Probably yes
- HTTP or other external response
- Browser storage
- URL or search parameters
- Request body
- Third-party payload
- Decoded JSON whose shape is not yet established
Usually no
- A value already validated at the boundary
- Ordinary component or application state
- An already trusted domain object
- A typed value that only needs property refinement
- A value that has not crossed another trust boundary
Validate when trust changes. Do not validate the same trusted value over and over. Narrow a property of a value you already trust with the property refinement guide.