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.
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.
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.
Type annotation or assertion
Fast inside code you control. It changes what the checker believes, not what JSON contains.
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.
Parser or decoder
Checks the value and constructs the trusted form together, with a place for field-level errors.
| Strategy | Evidence created | Failure information | Reconsider when |
|---|---|---|---|
| Type only | Only 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. |
| Guard | A 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 / decoder | A 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. |
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?
What does the boundary know?
Keep the endpoint fixed. Change the incoming value or the confidence strategy.
{
"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.
Hold the contract steady. Change the language.
The lab runs TypeScript. These panes show how each language expresses the same contract.
The 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 }; 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
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
}
};
} 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
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
}
};
} 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.
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()); 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
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.
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.
Keep the static type.
Accept the low ceremony, and add a check when the source of the value changes.
A guard can be enough.
Keep the rule small, nearby, and honest about the fact that failure details are not included.
Parse at the edge.
Return a trusted value or a structured issue. Reconsider when schema ownership or compatibility rules grow.
A client sends { query: 'cache', limit: '50' }.
The TypeScript interface says limit is a number. What should the boundary do?
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 →