← Concepts & practices
Choice Language and runtime models

Static types and runtime validation

Where does your confidence come from?

A type can make code easier to write without proving that a JSON request has the right shape. Keep the useful promise of static types, then decide what the boundary must check before the search code sees the value.

The judgment to keep

Use static types for code you control and runtime validation where values can be malformed, stale, or supplied by someone else.

TypeScriptGo One request. Three confidence strategies. Two implementations.
Start with one request

The type is not the boundary.

A search screen sends a JSON body. The search implementation wants a useful value: a non-empty query, a limit from 1 to 50, and a boolean for archived results. The TypeScript interface can describe that value, but the bytes arriving from a browser did not pass through the compiler.

The same distinction appears in Go. A struct tells Go code what fields mean; decoding and semantic checks decide whether this particular payload deserves to become that struct’s trusted input.

Our shared contractSearch for “timeouts”

We keep the operation fixed while changing how much evidence the boundary has.

Required
Query is non-empty and at most 80 Unicode characters.
Required
Limit is an integer from 1 through 50.
Required
includeArchived is a boolean; unknown fields are ignored.
Put the choices on the same table

Three ways to treat incoming data.

These options can combine. A parser still returns a statically typed value, and a schema library may generate both a runtime parser and a static type. The decision is about where the evidence is created and what the caller gets when it is missing.

A

Type annotation or assertion

Fast inside code you control. It changes what the checker believes, not what JSON contains.

Reasonable after a trusted decoder; unsafe as the decoder.
B

Hand-written type guard

Dependency-free and direct. It narrows a value to a type, but a boolean is thin feedback for a public boundary.

Useful for local branching; keep its rules beside the contract.
C

Parser or decoder

Checks the value and constructs the trusted form together, with a place for field-level errors.

Best at a boundary; costs explicit policy or a schema-library dependency.
What does each strategy promise?
StrategyEvidence createdFailure informationReconsider when
Type onlyOnly a compile-time relationship in this codebase.No boundary error; the first later operation sees the mismatch.The value crosses a process, storage, or user-controlled boundary.
GuardA boolean shape-and-rule check.The caller must supply its own reason and response.Clients need stable field errors or several callers repeat the policy.
Parser / decoderA validated value owned by the receiving side.Structured issue, field, and message can cross the boundary.Schema ownership, coercion, or unknown-field policy needs a stronger shared contract.
Hold the search fixed

Now make the input hostile.

The happy request does not distinguish the strategies. A client that sends "20" instead of 20 does. Missing fields and out-of-range numbers expose a second question: should the caller merely stop, or should it explain the contract violation?

Constraint lab

What does the boundary know?

Keep the endpoint fixed. Change the incoming value or the confidence strategy.

Incoming value
Confidence strategy
Runtime value
{
  "query": "timeouts",
  "limit": "20",
  "includeArchived": false
}

Choose a value and strategy, then evaluate it.

Why not just coerce the value?Coercion is another contract

Turning "20" into 20 can be a deliberate compatibility policy, but it should be visible in the contract. Coercion can hide a producer bug, and different fields may have different safe conversions. Parse first; coerce only when the accepted wire format says to.

Separate the two comparisons

Hold the contract steady. Change the language.

Read the boundary in TypeScript and Go.

The lab runs TypeScript. These panes show how each language expresses the same contract.

The static shape

TypeScriptStatic shape
search.ts · static shape
export type SearchRequest = {
	query: string;
	limit: number;
	includeArchived: boolean;
};

export type ValidationIssue = {
	field: string;
	message: string;
};

export type ParseResult =
	{ ok: true; value: SearchRequest } | { ok: false; issue: ValidationIssue };
GoStatic shape
search.go · static shape
type SearchRequest struct {
	Query           string `json:"query"`
	Limit           int    `json:"limit"`
	IncludeArchived bool   `json:"includeArchived"`
}

type ValidationError struct {
	Field   string
	Message string
}

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

Both declarations help code after the boundary. Neither one reads the incoming bytes.

The check that creates trust

TypeScriptRuntime validation
search.ts · parser
export function isSearchRequest(value: unknown): value is SearchRequest {
	if (!isRecord(value)) return false;
	return (
		typeof value.query === 'string' &&
		value.query.trim().length > 0 &&
		[...value.query].length <= 80 &&
		typeof value.limit === 'number' &&
		Number.isInteger(value.limit) &&
		value.limit >= 1 &&
		value.limit <= 50 &&
		typeof value.includeArchived === 'boolean'
	);
}

function validate(value: unknown): ValidationIssue | null {
	if (!isRecord(value)) {
		return { field: 'body', message: 'Expected a JSON object.' };
	}
	if (typeof value.query !== 'string') {
		return { field: 'query', message: 'Query must be a string.' };
	}
	if (value.query.trim().length === 0) {
		return { field: 'query', message: 'Query cannot be empty.' };
	}
	// Count code points, not UTF-16 units, so an emoji counts once.
	if ([...value.query].length > 80) {
		return { field: 'query', message: 'Query must be 80 characters or fewer.' };
	}
	if (typeof value.limit !== 'number' || !Number.isInteger(value.limit)) {
		return { field: 'limit', message: 'Limit must be an integer.' };
	}
	if (value.limit < 1 || value.limit > 50) {
		return { field: 'limit', message: 'Limit must be between 1 and 50.' };
	}
	if (typeof value.includeArchived !== 'boolean') {
		return { field: 'includeArchived', message: 'includeArchived must be a boolean.' };
	}
	return null;
}

export function parseSearchRequest(value: unknown): ParseResult {
	const issue = validate(value);
	if (issue) return { ok: false, issue };
	if (!isRecord(value)) return { ok: false, issue: { field: 'body', message: 'Invalid body.' } };
	return {
		ok: true,
		value: {
			query: value.query as string,
			limit: value.limit as number,
			includeArchived: value.includeArchived as boolean
		}
	};
}
GoRuntime validation
search.go · decoder
func decodeSearchRequest(payload []byte) (SearchRequest, error) {
	// Decode field by field so each failure names its field, as the TypeScript parser does.
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return SearchRequest{}, &ValidationError{Field: "body", Message: "Expected a JSON object."}
	}

	// Pointers tell a JSON null or a missing field apart from a real value.
	var query *string
	if json.Unmarshal(fields["query"], &query) != nil || query == nil {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query must be a string."}
	}
	if strings.TrimSpace(*query) == "" {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query cannot be empty."}
	}
	if utf8.RuneCountInString(*query) > 80 {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query must be 80 characters or fewer."}
	}

	var limit *float64
	if json.Unmarshal(fields["limit"], &limit) != nil || limit == nil || *limit != math.Trunc(*limit) {
		return SearchRequest{}, &ValidationError{Field: "limit", Message: "Limit must be an integer."}
	}
	if *limit < 1 || *limit > 50 {
		return SearchRequest{}, &ValidationError{Field: "limit", Message: "Limit must be between 1 and 50."}
	}

	var includeArchived *bool
	if json.Unmarshal(fields["includeArchived"], &includeArchived) != nil || includeArchived == nil {
		return SearchRequest{}, &ValidationError{Field: "includeArchived", Message: "includeArchived must be a boolean."}
	}

	return SearchRequest{Query: *query, Limit: int(*limit), IncludeArchived: *includeArchived}, nil
}

The TypeScript parser returns a union result. Go decodes each field into a pointer, so a missing field or a JSON null fails with the same field and message.

See the call siteTrust stops at one boundary
TypeScriptBoundary call site
search.ts · call site
export function handleSearchRequest(input: unknown) {
	const result = parseSearchRequest(input);
	if (!result.ok) {
		return { status: 400, body: { error: result.issue.field, message: result.issue.message } };
	}
	return {
		status: 200,
		body: {
			query: result.value.query,
			limit: result.value.limit,
			includeArchived: result.value.includeArchived
		}
	};
}
GoBoundary call site
search.go · call site
func handleSearchRequest(payload []byte) (int, string) {
	request, err := decodeSearchRequest(payload)
	if err != nil {
		validation := err.(*ValidationError)
		return 400, validation.Field + ": " + validation.Message
	}
	return 200, fmt.Sprintf("%s (%d, archived=%t)", request.Query, request.Limit, request.IncludeArchived)
}

func main() {
	status, body := handleSearchRequest([]byte(`{"query":"timeouts","limit":20,"includeArchived":false}`))
	fmt.Printf("%d %s\n", status, body)
}

The search operation receives the result only after the boundary has handled failure.

Copy the complete examplesStandard library only

These files are complete and copyable. The browser lab is not a general-purpose REPL; run the examples locally with the commands below.

TypeScriptComplete example
search.ts
export type SearchRequest = {
	query: string;
	limit: number;
	includeArchived: boolean;
};

export type ValidationIssue = {
	field: string;
	message: string;
};

export type ParseResult =
	{ ok: true; value: SearchRequest } | { ok: false; issue: ValidationIssue };

type RecordValue = Record<string, unknown>;

function isRecord(value: unknown): value is RecordValue {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function isSearchRequest(value: unknown): value is SearchRequest {
	if (!isRecord(value)) return false;
	return (
		typeof value.query === 'string' &&
		value.query.trim().length > 0 &&
		[...value.query].length <= 80 &&
		typeof value.limit === 'number' &&
		Number.isInteger(value.limit) &&
		value.limit >= 1 &&
		value.limit <= 50 &&
		typeof value.includeArchived === 'boolean'
	);
}

function validate(value: unknown): ValidationIssue | null {
	if (!isRecord(value)) {
		return { field: 'body', message: 'Expected a JSON object.' };
	}
	if (typeof value.query !== 'string') {
		return { field: 'query', message: 'Query must be a string.' };
	}
	if (value.query.trim().length === 0) {
		return { field: 'query', message: 'Query cannot be empty.' };
	}
	// Count code points, not UTF-16 units, so an emoji counts once.
	if ([...value.query].length > 80) {
		return { field: 'query', message: 'Query must be 80 characters or fewer.' };
	}
	if (typeof value.limit !== 'number' || !Number.isInteger(value.limit)) {
		return { field: 'limit', message: 'Limit must be an integer.' };
	}
	if (value.limit < 1 || value.limit > 50) {
		return { field: 'limit', message: 'Limit must be between 1 and 50.' };
	}
	if (typeof value.includeArchived !== 'boolean') {
		return { field: 'includeArchived', message: 'includeArchived must be a boolean.' };
	}
	return null;
}

export function parseSearchRequest(value: unknown): ParseResult {
	const issue = validate(value);
	if (issue) return { ok: false, issue };
	if (!isRecord(value)) return { ok: false, issue: { field: 'body', message: 'Invalid body.' } };
	return {
		ok: true,
		value: {
			query: value.query as string,
			limit: value.limit as number,
			includeArchived: value.includeArchived as boolean
		}
	};
}

export function handleSearchRequest(input: unknown) {
	const result = parseSearchRequest(input);
	if (!result.ok) {
		return { status: 400, body: { error: result.issue.field, message: result.issue.message } };
	}
	return {
		status: 200,
		body: {
			query: result.value.query,
			limit: result.value.limit,
			includeArchived: result.value.includeArchived
		}
	};
}

export function example() {
	return handleSearchRequest({ query: 'timeouts', limit: 20, includeArchived: false });
}

console.log(example());
GoComplete example
search.go
package main

import (
	"encoding/json"
	"fmt"
	"math"
	"strings"
	"unicode/utf8"
)

type SearchRequest struct {
	Query           string `json:"query"`
	Limit           int    `json:"limit"`
	IncludeArchived bool   `json:"includeArchived"`
}

type ValidationError struct {
	Field   string
	Message string
}

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


func decodeSearchRequest(payload []byte) (SearchRequest, error) {
	// Decode field by field so each failure names its field, as the TypeScript parser does.
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(payload, &fields); err != nil || fields == nil {
		return SearchRequest{}, &ValidationError{Field: "body", Message: "Expected a JSON object."}
	}

	// Pointers tell a JSON null or a missing field apart from a real value.
	var query *string
	if json.Unmarshal(fields["query"], &query) != nil || query == nil {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query must be a string."}
	}
	if strings.TrimSpace(*query) == "" {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query cannot be empty."}
	}
	if utf8.RuneCountInString(*query) > 80 {
		return SearchRequest{}, &ValidationError{Field: "query", Message: "Query must be 80 characters or fewer."}
	}

	var limit *float64
	if json.Unmarshal(fields["limit"], &limit) != nil || limit == nil || *limit != math.Trunc(*limit) {
		return SearchRequest{}, &ValidationError{Field: "limit", Message: "Limit must be an integer."}
	}
	if *limit < 1 || *limit > 50 {
		return SearchRequest{}, &ValidationError{Field: "limit", Message: "Limit must be between 1 and 50."}
	}

	var includeArchived *bool
	if json.Unmarshal(fields["includeArchived"], &includeArchived) != nil || includeArchived == nil {
		return SearchRequest{}, &ValidationError{Field: "includeArchived", Message: "includeArchived must be a boolean."}
	}

	return SearchRequest{Query: *query, Limit: int(*limit), IncludeArchived: *includeArchived}, nil
}


func handleSearchRequest(payload []byte) (int, string) {
	request, err := decodeSearchRequest(payload)
	if err != nil {
		validation := err.(*ValidationError)
		return 400, validation.Field + ": " + validation.Message
	}
	return 200, fmt.Sprintf("%s (%d, archived=%t)", request.Query, request.Limit, request.IncludeArchived)
}

func main() {
	status, body := handleSearchRequest([]byte(`{"query":"timeouts","limit":20,"includeArchived":false}`))
	fmt.Printf("%d %s\n", status, body)
}

TypeScriptnode --experimental-strip-types search.ts

Gogo run search.go

A production boundary

Keep the trusted value local.

A frontend form, another service, a database row, and an environment variable are all sources where static evidence can stop applying. Validate close to the boundary, then pass a typed value inward. Do not make every downstream function remember that it might still be looking at JSON.

The receiving side owns the runtime check. The producer may be helpful, but it cannot make an independently deployed consumer safe by exporting an interface alone.

Build UIs?See where this shows up in your components.

A form type does not validate a response.

A React or Svelte component can type its local form state, while the network response still needs decoding. Map the parsed request or response into view state once, near the request boundary. The component should not discover a string limit while rendering a result count.

Keep user-facing copy separate from machine-readable fields. A translated error message should not change which validation branch ran.

Make a conditional recommendation

Keep both kinds of confidence.

Inside a module where values are constructed by typed code, a static type may be enough. At a JSON, storage, or user boundary, parse into a trusted value. Use a guard when a local boolean branch is all the caller needs; use a parser when rejection needs a stable explanation or the validated value should have one owner.

Typed code you own end to end

Keep the static type.

Accept the low ceremony, and add a check when the source of the value changes.

One local branch

A guard can be enough.

Keep the rule small, nearby, and honest about the fact that failure details are not included.

Public or persisted input

Parse at the edge.

Return a trusted value or a structured issue. Reconsider when schema ownership or compatibility rules grow.

Decision practice

A client sends { query: 'cache', limit: '50' }.

The TypeScript interface says limit is a number. What should the boundary do?

Leave yourself a useful note

Keep the boundary decision.

“We use TypeScript” is not enough for the next person. Record where static evidence ends and what turns external data into an input the rest of the system can trust.

Why
The search operation must not receive malformed or out-of-range request data.
What
Keep a static type in the core and parse JSON at the boundary. The parser is code we own and keep in step with the contract.
Constraint
The client or stored payload can change independently of this code.
Fallback
Input the parser rejects gets a 400 naming the field and the problem; the search code never runs on it.
Reconsider when
Coercion, shared schemas, generated clients, or unknown-field policy becomes part of the public contract.

A decision note to adapt to your own boundary. Nothing here is saved to an account.

Explore more concepts & practices →