← Concepts & practices
Pattern Boundaries and contracts

Validation at the edge

Check before you trust.

A team-invite endpoint receives JSON from a browser, a script, or a client written before your latest release. TypeScript types and Go structs describe what the program hopes to receive; neither one, on its own, inspects the bytes. Put a runtime check at the boundary, return a value with guarantees, and leave authorization and business policy to the domain that owns them.

The judgment to keep

Check untrusted shape once at the boundary and translate the result into a stable contract. A successful check gives the domain a typed value; it does not give the caller permission to do something, guarantee uniqueness, or settle a business rule.

TypeScriptGo One team invite · three validation implementations
Start with the receiver

The type annotation is not the boundary.

The browser submits email, displayName, and role. The server receives an unknown value. It may have a missing key, a number where text belongs, an unrecognized role, extra fields, or no object shape at all. A TypeScript annotation such as const invite = body as Invite changes only the compiler’s view of that value.

The first useful question is not “which validation package should we use?” It is “where does untrusted data become trusted?” That moment belongs at the HTTP, message, file, or environment boundary—before the value enters application code that assumes the fields are present.

Parse once at the edge, return what passed, and make failure actionable for the caller.

Read the tempting castTypeScript · a compile-time promise only
invite.ts · compile-time cast
// A cast only changes TypeScript's view; it does not inspect the request body.
export function unsafeInvite(input: unknown): Invite {
	return input as Invite;
}

The cast is useful as a contrast because it looks so close to the finished code. If the body contains role: 'owner', the cast does not reject it. It only suppresses the compiler’s question. The runtime still holds the original object.

Name the choices

Three implementations can share one boundary contract.

A schema library, a hand-written parser, and Go struct tags are different ways to describe and run field checks. They should still agree on the useful public result: either a normalized Invite, or a list of issues with a field path, stable code, and message.

Choice 01

Schema library

Declare a runtime shape and let a package collect paths and issues.

Good at
Repeated shapes and nested issue paths.
Own
Translate library output into your contract.
Choice 02

Hand-rolled parser

Keep checks and normalization in a small function you can read end to end.

Good at
Exact codes, messages, and small stable inputs.
Own
Prevent copied rules from drifting.
Choice 03

Go struct tags

Place field metadata beside a decoded transport struct.

Good at
Readable field rules close to the payload.
Own
Remember tags need a runtime interpreter.
What each choice gives you
ChoiceAt the edgeAfter successStill separate
Schema libraryRuntime shape plus collected issuesInferred or adapted valuePublic response wording and domain policy
Hand-rolledExplicit checks and normalizationOwned result typeRule maintenance as shapes grow
Struct tagsDecoded struct plus tag validatorTyped payloadTag runtime and cross-field rules
Read the Go tagsGo · metadata beside the transport shape
invite.go · struct tags
// InvitePayload is the transport shape. The json tags name the wire fields; the label
// and validate tags are rules that do nothing until code reads them at runtime.
type InvitePayload struct {
	Email       string `json:"email" label:"Email" validate:"required,email,lowercase"`
	DisplayName string `json:"displayName" label:"Display name" validate:"required,min=2,max=80"`
	Role        string `json:"role" label:"Role" validate:"required,oneof=member admin"`
}

// json.Unmarshal checks JSON syntax and fills a struct; it enforces none of the
// validate tags, and it fails the whole body when one field has the wrong type.
func DecodeInvite(body []byte) (InvitePayload, error) {
	var payload InvitePayload
	if err := json.Unmarshal(body, &payload); err != nil {
		return InvitePayload{}, err
	}
	return payload, nil
}

The tags make intent easy to find, but json.Unmarshal does not enforce them, and it rejects the whole body when one field has the wrong type. Something must read required, email, min, max, and oneof at runtime. The example’s ValidateTags, shown with the parsers below, does that with reflect; in production a validator package does it, and the boundary translates its field errors into the same public issue shape as the TypeScript endpoint.

Follow the invite

Watch shape stop at the edge and policy continue inside.

Choose a validation style and an incoming payload. The lab names the hand-off: malformed data gets issues and stops; structurally valid data becomes an Invite before the domain checks who may create it.

Team invite endpoint

Change the checker and the payload.

Runs a local boundary model
Hand-rolled TypeScript parser A complete invite
01HTTP handler receives untrusted JSON
02Hand-rolled TypeScript parser checks and normalizes the payload
03Invite value crosses the boundary
04Invite service applies domain policy
Edge result

accepted

Issues

0

Reaches domain

yes

Placement

Parse unknown input at the HTTP boundary into an Invite.

A successful boundary check should hand domain code a value with a narrower, honest type.

Watch for Keep parsing at the edge; do not make every service method re-check the same raw fields.

Runtime note Every check is visible in one function; the result type is the public contract.

The controls change a local model; they do not call an API or install a validator.
Read the three parsersTypeScript and Go · the same contract, different syntax
invite.ts · hand-rolled and schema-style
// Absent and null both count as "not supplied", as they do when Go decodes into a string.
function readText(input: Record<string, unknown>, path: string, label: string, issues: Issue[]) {
	const value = input[path];
	if (value === undefined || value === null || (typeof value === 'string' && !value.trim())) {
		issues.push(issue(path, 'required', `${label} is required.`));
		return null;
	}
	if (typeof value !== 'string') {
		issues.push(issue(path, 'string', `${label} must be text.`));
		return null;
	}
	return value.trim();
}

// Hand-rolled: every check is written out for this one payload.
export function parseInvite(input: unknown): ParseResult {
	if (!isRecord(input)) {
		return { ok: false, issues: [issue('$', 'object', 'Invite body must be an object.')] };
	}
	const issues: Issue[] = [];

	const email = readText(input, 'email', 'Email', issues);
	if (email !== null && !emailPattern.test(email)) {
		issues.push(issue('email', 'email', 'Enter a valid email address.'));
	}

	const displayName = readText(input, 'displayName', 'Display name', issues);
	if (displayName !== null && [...displayName].length < 2) {
		issues.push(issue('displayName', 'min_length', 'Display name must be at least 2 characters.'));
	} else if (displayName !== null && [...displayName].length > 80) {
		issues.push(issue('displayName', 'max_length', 'Display name must be at most 80 characters.'));
	}

	const role = readText(input, 'role', 'Role', issues);
	if (role !== null && role !== 'member' && role !== 'admin') {
		issues.push(issue('role', 'one_of', 'Role must be member or admin.'));
	}

	if (issues.length > 0 || email === null || displayName === null) return { ok: false, issues };
	return {
		ok: true,
		value: Object.freeze({ email: email.toLowerCase(), displayName, role: role as InviteRole })
	};
}

// Schema-style: the rules are data, and one generic function interprets any schema.
// A Zod or Valibot schema plays the same part; this one keeps the example dependency-free.
type FieldRules = Readonly<{
	label: string;
	email?: true;
	min?: number;
	max?: number;
	oneOf?: readonly string[];
	lowercase?: true;
}>;

export const inviteSchema = {
	email: { label: 'Email', email: true, lowercase: true },
	displayName: { label: 'Display name', min: 2, max: 80 },
	role: { label: 'Role', oneOf: ['member', 'admin'] }
} as const satisfies Record<keyof Invite, FieldRules>;

export function parseWithSchema<Field extends string>(
	schema: Readonly<Record<Field, FieldRules>>,
	input: unknown
): Readonly<{ ok: true; value: Record<Field, string> }> | Readonly<{ ok: false; issues: Issue[] }> {
	if (!isRecord(input)) {
		return { ok: false, issues: [issue('$', 'object', 'Invite body must be an object.')] };
	}
	const issues: Issue[] = [];
	const value: Partial<Record<Field, string>> = {};
	for (const path of Object.keys(schema) as Field[]) {
		const rules: FieldRules = schema[path];
		const text = readText(input, path, rules.label, issues);
		if (text === null) continue;
		const length = [...text].length;
		if (rules.email && !emailPattern.test(text)) {
			issues.push(issue(path, 'email', 'Enter a valid email address.'));
		} else if (rules.min !== undefined && length < rules.min) {
			issues.push(
				issue(path, 'min_length', `${rules.label} must be at least ${rules.min} characters.`)
			);
		} else if (rules.max !== undefined && length > rules.max) {
			issues.push(
				issue(path, 'max_length', `${rules.label} must be at most ${rules.max} characters.`)
			);
		} else if (rules.oneOf && !rules.oneOf.includes(text)) {
			issues.push(issue(path, 'one_of', `${rules.label} must be ${rules.oneOf.join(' or ')}.`));
		} else {
			value[path] = rules.lowercase ? text.toLowerCase() : text;
		}
	}
	if (issues.length > 0) return { ok: false, issues };
	return { ok: true, value: value as Record<Field, string> };
}

export function parseInviteSchemaStyle(input: unknown): ParseResult {
	const parsed = parseWithSchema(inviteSchema, input);
	if (!parsed.ok) return parsed;
	const { email, displayName, role } = parsed.value;
	return { ok: true, value: Object.freeze({ email, displayName, role: role as InviteRole }) };
}

export type AuthorizationResult =
	Readonly<{ ok: true; invite: Invite }> | Readonly<{ ok: false; reason: string }>;

export function authorizeInvite(
	invite: Invite,
	requesterRole: 'member' | 'admin'
): AuthorizationResult {
	if (invite.role === 'admin' && requesterRole !== 'admin') {
		return { ok: false, reason: 'Only an admin can create an admin invite.' };
	}
	return { ok: true, invite };
}
invite.go · tag validator
// ValidateTags reads each field's json, label, and validate tags with reflect and
// applies the rules to the raw body, so a wrong type is reported per field. It fills
// target with the trimmed values. A package such as go-playground/validator does this
// work in production; the boundary still translates its errors into this Issue shape.
func ValidateTags(body []byte, target any) []Issue {
	if !json.Valid(body) {
		return []Issue{{Path: "$", Code: "json", Message: "Invite body must be valid JSON."}}
	}
	var fields map[string]json.RawMessage
	if json.Unmarshal(body, &fields) != nil || fields == nil {
		return []Issue{{Path: "$", Code: "object", Message: "Invite body must be an object."}}
	}
	issues := []Issue{}
	value := reflect.ValueOf(target).Elem()
	for index := 0; index < value.NumField(); index++ {
		field := value.Type().Field(index)
		path := field.Tag.Get("json")
		label := field.Tag.Get("label")
		// Absent and null both count as "not supplied".
		var text string
		if raw, ok := fields[path]; ok && !bytes.Equal(raw, []byte("null")) {
			if json.Unmarshal(raw, &text) != nil {
				issues = append(issues, Issue{Path: path, Code: "string", Message: label + " must be text."})
				continue
			}
		}
		text = strings.TrimSpace(text)
		if issue, ok := checkRules(path, label, text, field.Tag.Get("validate")); !ok {
			issues = append(issues, issue)
			continue
		}
		if strings.Contains(field.Tag.Get("validate"), "lowercase") {
			text = strings.ToLower(text)
		}
		value.Field(index).SetString(text)
	}
	return issues
}

func checkRules(path, label, text, rules string) (Issue, bool) {
	length := utf8.RuneCountInString(text)
	for _, rule := range strings.Split(rules, ",") {
		name, argument, _ := strings.Cut(rule, "=")
		limit, _ := strconv.Atoi(argument)
		switch {
		case name == "required" && text == "":
			return Issue{Path: path, Code: "required", Message: label + " is required."}, false
		case name == "email" && !emailPattern.MatchString(text):
			return Issue{Path: path, Code: "email", Message: "Enter a valid email address."}, false
		case name == "min" && length < limit:
			return Issue{Path: path, Code: "min_length", Message: fmt.Sprintf("%s must be at least %d characters.", label, limit)}, false
		case name == "max" && length > limit:
			return Issue{Path: path, Code: "max_length", Message: fmt.Sprintf("%s must be at most %d characters.", label, limit)}, false
		case name == "oneof" && !contains(strings.Fields(argument), text):
			return Issue{Path: path, Code: "one_of", Message: fmt.Sprintf("%s must be %s.", label, strings.Join(strings.Fields(argument), " or "))}, false
		}
	}
	return Issue{}, true
}

func contains(options []string, text string) bool {
	for _, option := range options {
		if option == text {
			return true
		}
	}
	return false
}

func ParseInvite(body []byte) (Invite, []Issue) {
	var payload InvitePayload
	if issues := ValidateTags(body, &payload); len(issues) > 0 {
		return Invite{}, issues
	}
	return Invite(payload), nil
}

// ForbiddenError carries a reason that is safe to show the caller.
type ForbiddenError struct{ Reason string }

func (err ForbiddenError) Error() string { return err.Reason }

func AuthorizeInvite(invite Invite, requesterRole string) error {
	if invite.Role == "admin" && requesterRole != "admin" {
		return ForbiddenError{Reason: "Only an admin can create an admin invite."}
	}
	return nil
}

parseInvite writes every check by hand. parseInviteSchemaStyle declares the rules as a table and hands it to a generic interpreter, the part a schema library supplies. Go’s ValidateTags reads the same rules from struct tags. All three trim, lowercase the email, and return the same issues for the same payload; a shared case file pins that. The endpoint, under “At the call site” below, maps issues to 400 and checks admin authorization only after parsing succeeds.

Make the seam explicit

Choose the owner of each question.

Test the boundary, the public failure shape, and the rule that needs business context.

A request body arrives as unknown JSON. What should the invite service receive?
A schema library returns rich issues. What belongs in the API response?
A Go payload has validate tags. What makes those tags do anything?
The payload is structurally valid, but the requester cannot invite an admin. Where does that rule live?
Feedback stays on this page; it is not saved.
Put it in a request

The handler should get boring after parsing.

A boundary adapter has a short job: decode, validate, normalize, and translate failure. Once it has an Invite, the service can read invite.role without repeating whether it is a string or whether the value belongs to the allowed set.

Keep the public error stable even if the implementation changes from hand-rolled checks to a schema package, or from one Go validator to another. Tests should pin the response your clients see, not a vendor’s private issue wording.

The first version accepts a cast or decoded payload and leaves the input shape implicit.

TypeScriptReading
invite.ts
// A cast only changes TypeScript's view; it does not inspect the request body.
export function unsafeInvite(input: unknown): Invite {
	return input as Invite;
}
GoAlongside
invite.go
// InvitePayload is the transport shape. The json tags name the wire fields; the label
// and validate tags are rules that do nothing until code reads them at runtime.
type InvitePayload struct {
	Email       string `json:"email" label:"Email" validate:"required,email,lowercase"`
	DisplayName string `json:"displayName" label:"Display name" validate:"required,min=2,max=80"`
	Role        string `json:"role" label:"Role" validate:"required,oneof=member admin"`
}

// json.Unmarshal checks JSON syntax and fills a struct; it enforces none of the
// validate tags, and it fails the whole body when one field has the wrong type.
func DecodeInvite(body []byte) (InvitePayload, error) {
	var payload InvitePayload
	if err := json.Unmarshal(body, &payload); err != nil {
		return InvitePayload{}, err
	}
	return payload, nil
}
Boundary owns
  • JSON and form decoding
  • Shape, field rules, and normalization
  • Field paths and public issue codes
Domain owns
  • Authorization and role policy
  • Uniqueness and current-state checks
  • Invariants that need context
Recognize it in UI code

Forms are boundaries too.

Build frontends?Every form submit crosses the edge before the server sees it.

Where it already is in your components

A sign-up or invite form reads FormData, and every value in it is a string or a file. The moment you turn it into an object your component trusts, you are standing at an edge. The client can improve the experience, but the server remains the authority.

When you have to own it

When the form sends an invite, own one parser call: turn FormData into an Invite or field issues, show those issues, and send only the parsed value. The wild versions let a cast stand in for that runtime contract.

Parse FormData once, show field issues, and send only a value the service can trust.

ReactAlready in your code
textbook.tsx · parse before send
import { useState } from 'react';

type Invite = { email: string; displayName: string; role: 'member' | 'admin' };
type Issue = { path: string; message: string };
type ParseResult = { ok: true; value: Invite } | { ok: false; issues: Issue[] };

// Imported from the boundary module in the application.
declare function parseInvite(input: unknown): ParseResult;
declare function sendInvite(invite: Invite): Promise<void>;

export function InviteForm() {
	const [issues, setIssues] = useState<Issue[]>([]);

	return (
		<form
			onSubmit={(event) => {
				event.preventDefault();
				const input = Object.fromEntries(new FormData(event.currentTarget));
				const parsed = parseInvite(input);
				if (!parsed.ok) {
					setIssues(parsed.issues);
					return;
				}
				setIssues([]);
				void sendInvite(parsed.value);
			}}
		>
			<label>
				Email <input name="email" type="email" />
			</label>
			<label>
				Display name <input name="displayName" />
			</label>
			<label>
				Role{' '}
				<select name="role">
					<option value="member">Member</option>
					<option value="admin">Admin</option>
				</select>
			</label>
			<button type="submit">Invite teammate</button>
			<ul>
				{issues.map((issue) => (
					<li key={issue.path}>
						{issue.path}: {issue.message}
					</li>
				))}
			</ul>
		</form>
	);
}
Keep the edges sharp

The check is only as useful as its contract.

01

Types do not run

TypeScript disappears at runtime, and Go tags are inert until a decoder or validator reads them.

02

Errors are API design

Return stable paths and codes clients can use. Keep package-specific wording behind the adapter.

03

Unknown fields need a policy

Reject, strip, or preserve extra keys deliberately. Accidental behavior becomes compatibility debt.

04

Cross-field rules need context

Validation can establish shape. Authorization, uniqueness, and state-dependent invariants belong later.

Make the call

Choose the smallest tool that owns the whole question.

Use a schema library when many boundaries share nested shapes and consistent issue collection. Use a hand-rolled parser when the contract is small and exact public behavior matters more than declaration reuse. Use struct tags when Go’s transport type is the natural home for field metadata—but pair them with an actual runtime check.

Whichever you choose, keep one testable adapter between the wire and the domain. Its return value should make the successful guarantee visible, and its failure should tell the caller how to repair the input.

Keep this questionSee where this shows up in your components.

Where does untrusted data become a value your domain is allowed to trust?

Take the idea with you

Validation is one link in a longer contract.

The durable habit is simple: find the first point where data changes owners, make the check visible there, and give the next owner the narrowest honest value.

Connections to follow nextRelated lessons
Why
Requests arrive from clients, scripts, and old bundles the server does not control.
What
One parser at the endpoint returns a normalized Invite or issues with paths and stable codes.
Constraint
Authorization and uniqueness need requester context, so they stay in the domain.
Fallback
Anything that fails the parse stops at the edge with a 400 and field issues.
Reconsider when
Many boundaries share nested shapes and a schema library would stop rules drifting.
10 / Practice in code

Only pass the next layer what it can trust.

Implement an invite parser, a webhook event check, or safe pagination defaults. The workspace keeps validation at the boundary: it checks shape and returns actionable issues, while domain policy and outside effects remain with their owners.

Validation practice 10 min

Return a trusted invite or field issues

This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback

Checking your sign-in status. Your lesson remains available while we check.

TypeScript Go

Validation practice 10 min

This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback

Return a trusted invite or field issues

TYPESCRIPT

Work item AUTH-184

Return a trusted invite or field issues

Implementation exercise Ready

Context

The invite endpoint receives a decoded JSON object from a browser or an older client. The domain needs a normalized email, a nonblank display name, and a supported role; malformed fields should stop at the boundary with useful issue codes.

Acceptance criteria
  1. AC-1Accept email, displayName, and role only when they have the expected string shape.
  2. AC-2Trim the name and trim plus lowercase the email before returning it.
  3. AC-3Require an email-like address and allow only member or admin roles.
  4. AC-4Collect all field issues in a stable order; do not add authorization policy here.
Notes
  • The email check is intentionally a small boundary check, not a claim that a mailbox exists.
  • Issue codes are public strings such as email:format and role:unsupported.
  • TypeScript accepts unknown field values. Go receives map[string]interface{} and checks assertions.

Copy the ticket to research the problem in your own notes or AI tool. Your code stays here.

Your implementation

Edit the function in the editor. Run the visible checks as often as you like; your code stays in this tab.

Checks cover

  • Valid invite normalizes the email
  • Valid invite trims the display name
  • Admin is a valid role, not an authorization decision
  • All invalid fields return issues in stable order
  • A value with the wrong runtime type is rejected