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
// 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.
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.
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.
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.
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.
| Choice | At the edge | After success | Still separate |
|---|---|---|---|
| Schema library | Runtime shape plus collected issues | Inferred or adapted value | Public response wording and domain policy |
| Hand-rolled | Explicit checks and normalization | Owned result type | Rule maintenance as shapes grow |
| Struct tags | Decoded struct plus tag validator | Typed payload | Tag runtime and cross-field rules |
Read the Go tagsGo · metadata beside the transport shape
// 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.
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.
Change the checker and the payload.
accepted
0
yes
Parse unknown input at the HTTP boundary into an Invite.
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.
Read the three parsersTypeScript and Go · the same contract, different syntax
// 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 };
} // 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.
Choose the owner of each question.
Test the boundary, the public failure shape, and the rule that needs business context.
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.
// A cast only changes TypeScript's view; it does not inspect the request body.
export function unsafeInvite(input: unknown): Invite {
return input as Invite;
} // 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
} - JSON and form decoding
- Shape, field rules, and normalization
- Field paths and public issue codes
- Authorization and role policy
- Uniqueness and current-state checks
- Invariants that need context
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.
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>
);
}
The check is only as useful as its contract.
Types do not run
TypeScript disappears at runtime, and Go tags are inert until a decoder or validator reads them.
Errors are API design
Return stable paths and codes clients can use. Keep package-specific wording behind the adapter.
Unknown fields need a policy
Reject, strip, or preserve extra keys deliberately. Accidental behavior becomes compatibility debt.
Cross-field rules need context
Validation can establish shape. Authorization, uniqueness, and state-dependent invariants belong later.
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?
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
- Parse, don’t validate names the value that should come back from a check.
- Domain model vs DTO separates transport shape from business meaning.
- Errors across a boundary continues the story for failures after a service has made its decision.
- Why
- Requests arrive from clients, scripts, and old bundles the server does not control.
- What
- One parser at the endpoint returns a normalized
Inviteor 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
400and field issues. - Reconsider when
- Many boundaries share nested shapes and a schema library would stop rules drifting.
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.