Input validation and error handling
Treat everything from outside your code as untrusted, and check it once, at the boundary, in layers: syntax, shape, types, field rules, cross-field rules, then rules that need stored state. Turn it into a typed value that is valid by construction, so the code inside never re-checks. Then decide two things on purpose: fail fast or collect all errors, and throw or return a result. Validation is O(size of input); the expensive mistake is doing it after you’ve already changed state.
The idea in plain words
Section titled “The idea in plain words”Think of passport control. Every traveller is checked once, at the border. Inside the country, nobody asks for your passport at every shop. If border control were sloppy, every shop would need its own checks, and some would forget.
Code works the same way. The boundary is wherever data enters: an HTTP handler, a CLI argument, a file you read, a message from a queue, a function in a public library. Check there, then pass on a value whose type already promises it is valid: an Endpoint with a port between 1 and 65535, not a str that someone hopefully checked.
This idea has a name: parse, don’t validate (Alexis King, 2019). A validator answers yes or no and throws the knowledge away. A parser returns a new, more precise value, or an error. The type system then carries the proof for you.
Checks come in layers, and each layer only makes sense if the one before it passed:
| Layer | Example question | Typical response |
|---|---|---|
| Syntax | Is this JSON / CSV / a number at all? | 400 Bad Request |
| Shape | Is it an object? Are required fields present? Any unknown fields? | 400 / 422 |
| Types | Is age an integer, not "36" or true? |
422 |
| Field rules | Is age between 13 and 130? Is plan one of the allowed values? |
422 |
| Cross-field | A team plan needs seats >= 2; end is after start |
422 |
| State | Is this email already registered? Does the account exist? | 404 / 409 |
See it
Section titled “See it”Edit the request body or pick a preset, and switch strategies. Watch which checks run, which are skipped because their input is unusable, and whether the database is touched. Try wrong types: "36" is not an integer, and true is not a seat count.
$parses as JSON$is an object$no unknown fieldsemailpresentagepresentplanpresentemailis a stringageis an integerplanis a stringseatsis an integeremailname@domain, ≤ 254 chars: must look like name@domain.tldage13 ≤ age ≤ 130: must be between 13 and 130planone of free / pro / team: must be one of free, pro, teamseats1 ≤ seats ≤ 500: must be between 1 and 500seatsteam plan ⇒ seats ≥ 2 (can't run: depends on a failed check)emailnot already registered (can't run: depends on a failed check){
"error": "validation_failed",
"errors": [
{
"field": "email",
"message": "must look like name@domain.tld"
},
{
"field": "age",
"message": "must be between 13 and 130"
},
{
"field": "plan",
"message": "must be one of free, pro, team"
},
{
"field": "seats",
"message": "must be between 1 and 500"
}
]
}When to reach for it
Section titled “When to reach for it”| The problem says… | What it’s really asking |
|---|---|
| “return an error for invalid input” / “malformed rows are skipped” | A precise error contract. Which inputs are invalid, and what exactly is returned for each? |
| “report all problems” / “list every error per row” | Collect-all validation with a stable order of messages. |
| “returns None / false and changes nothing” | Validate everything first, then mutate. No partial updates. |
| “errors are returned as strings, not raised” | The spec chose result values over exceptions. Follow it exactly. |
| an API endpoint, a file import, a command handler | A boundary: parse into typed values here, trust them inside. |
How it works
Section titled “How it works”Collect errors per field, stop between layers
Section titled “Collect errors per field, stop between layers”- Syntax first. If the bytes don’t parse, nothing else can run. Report where the parse failed.
- One rule chain per field. Type check, then range or format rules. A later rule only runs if the earlier ones passed: checking the range of a string is meaningless.
- Pick a strategy. Fail fast returns at the first error. Collect-all records the error and moves on to the next field.
- Cross-field and stateful rules last, and only when every field they read is valid. That also keeps the database untouched for requests that are already rejected.
import jsonfrom collections.abc import Callablefrom dataclasses import dataclassfrom typing import Any
PLANS = ("free", "pro", "team")Rule = tuple[Callable[[Any], bool], str]
# 2. one rule chain per field; a rule only runs if the ones before it passedRULES: dict[str, list[Rule]] = { "email": [(lambda v: isinstance(v, str), "required string"), (lambda v: len(v) <= 254 and v.count("@") == 1 and "" not in v.split("@"), "must look like name@domain")], "age": [(lambda v: type(v) is int, "required integer"), # type() excludes bool, an int subclass (lambda v: 13 <= v <= 130, "must be between 13 and 130")], "plan": [(lambda v: v in PLANS, f"must be one of {', '.join(PLANS)}")],}
@dataclass(frozen=True)class FieldError: field: str message: str
def validate_signup(body: str, fail_fast: bool = False) -> list[FieldError]: """Check a JSON signup request. Returns every problem found ([] means valid).""" try: data = json.loads(body) # 1. syntax first: nothing else can run on broken JSON except json.JSONDecodeError as e: return [FieldError("$", f"invalid JSON at line {e.lineno}, column {e.colno}")] if not isinstance(data, dict): return [FieldError("$", "expected a JSON object")]
errors: list[FieldError] = [] for field, chain in RULES.items(): for ok, message in chain: if not ok(data.get(field)): errors.append(FieldError(field, message)) if fail_fast: return errors # 3. fail fast: stop at the first problem break # collect all: move on to the next field # 4. cross-field rules run only once every field they read is known to be valid seats = data.get("seats", 1) if not errors and data["plan"] == "team" and not (type(seats) is int and seats >= 2): errors.append(FieldError("seats", "team plan needs at least 2 seats")) return errors
if __name__ == "__main__": assert validate_signup('{"email": "ada@example.com", "age": 36, "plan": "pro"}') == [] bad = '{"email": "ada@", "age": true, "plan": "gold"}' assert [e.field for e in validate_signup(bad)] == ["email", "age", "plan"] # collect all assert [e.field for e in validate_signup(bad, fail_fast=True)] == ["email"] # first only team = '{"email": "a@b.co", "age": 40, "plan": "team", "seats": 1}' assert validate_signup(team) == [FieldError("seats", "team plan needs at least 2 seats")] assert validate_signup('{"email": 1,}')[0].message.startswith("invalid JSON at line 1") assert validate_signup("[1, 2]")[0].message == "expected a JSON object" print("ok")import assert from "node:assert/strict";
interface FieldError { field: string; message: string }type Rule = [ok: (v: unknown) => boolean, message: string];
const PLANS = ["free", "pro", "team"];
// 2. one rule chain per field; a rule only runs if the ones before it passedconst RULES: Record<string, Rule[]> = { email: [ [(v) => typeof v === "string", "required string"], [(v) => { const s = v as string; const parts = s.split("@"); return s.length <= 254 && parts.length === 2 && !parts.includes(""); }, "must look like name@domain"], ], age: [ [(v) => Number.isInteger(v), "required integer"], // rejects "36", 36.5, true, NaN [(v) => (v as number) >= 13 && (v as number) <= 130, "must be between 13 and 130"], ], plan: [[(v) => typeof v === "string" && PLANS.includes(v), `must be one of ${PLANS.join(", ")}`]],};
export function validateSignup(body: string, failFast = false): FieldError[] { let data: unknown; try { data = JSON.parse(body); // 1. syntax first: nothing else can run on broken JSON } catch (e) { return [{ field: "$", message: `invalid JSON: ${(e as Error).message}` }]; } if (typeof data !== "object" || data === null || Array.isArray(data)) return [{ field: "$", message: "expected a JSON object" }]; const obj = data as Record<string, unknown>;
const errors: FieldError[] = []; for (const [field, chain] of Object.entries(RULES)) { for (const [ok, message] of chain) { if (!ok(obj[field])) { errors.push({ field, message }); if (failFast) return errors; // 3. fail fast: stop at the first problem break; // collect all: move on to the next field } } } // 4. cross-field rules run only once every field they read is known to be valid const seats = obj.seats ?? 1; if (errors.length === 0 && obj.plan === "team" && !(Number.isInteger(seats) && (seats as number) >= 2)) { errors.push({ field: "seats", message: "team plan needs at least 2 seats" }); } return errors;}
assert.deepEqual(validateSignup('{"email": "ada@example.com", "age": 36, "plan": "pro"}'), []);const bad = '{"email": "ada@", "age": true, "plan": "gold"}';assert.deepEqual(validateSignup(bad).map((e) => e.field), ["email", "age", "plan"]); // collect allassert.deepEqual(validateSignup(bad, true).map((e) => e.field), ["email"]); // first onlyconst team = '{"email": "a@b.co", "age": 40, "plan": "team", "seats": 1}';assert.deepEqual(validateSignup(team), [{ field: "seats", message: "team plan needs at least 2 seats" }]);assert.match(validateSignup('{"email": 1,}')[0].message, /^invalid JSON/);assert.equal(validateSignup("[1, 2]")[0].message, "expected a JSON object");console.log("ok");using System.Text.Json;
if (Signup.Validate("""{"email": "ada@example.com", "age": 36, "plan": "pro"}""").Count != 0) throw new Exception("valid");var bad = """{"email": "ada@", "age": true, "plan": "gold"}""";if (string.Join(",", Signup.Validate(bad).Select(e => e.Field)) != "email,age,plan") throw new Exception("collect all");if (string.Join(",", Signup.Validate(bad, failFast: true).Select(e => e.Field)) != "email") throw new Exception("fail fast");var team = """{"email": "a@b.co", "age": 40, "plan": "team", "seats": 1}""";if (Signup.Validate(team).Single() != new FieldError("seats", "team plan needs at least 2 seats")) throw new Exception("cross-field");if (!Signup.Validate("""{"email": 1,}""")[0].Message.StartsWith("invalid JSON")) throw new Exception("syntax");if (Signup.Validate("[1, 2]")[0].Message != "expected a JSON object") throw new Exception("shape");Console.WriteLine("ok");
record FieldError(string Field, string Message);
static class Signup{ static readonly string[] Plans = ["free", "pro", "team"];
// 2. one rule chain per field; a rule only runs if the ones before it passed static readonly (string Field, (Func<JsonElement, bool> Ok, string Message)[] Chain)[] Rules = [ ("email", [(v => v.ValueKind == JsonValueKind.String, "required string"), (v => v.GetString()!.Split('@') is [var a, var b] && a != "" && b != "" && v.GetString()!.Length <= 254, "must look like name@domain")]), ("age", [(v => v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out _), "required integer"), // true is not a number here (v => v.GetInt32() is >= 13 and <= 130, "must be between 13 and 130")]), ("plan", [(v => v.ValueKind == JsonValueKind.String && Plans.Contains(v.GetString()), $"must be one of {string.Join(", ", Plans)}")]), ];
public static List<FieldError> Validate(string body, bool failFast = false) { JsonElement data; try { data = JsonDocument.Parse(body).RootElement; } // 1. syntax first catch (JsonException e) { return [new("$", $"invalid JSON: {e.Message}")]; } if (data.ValueKind != JsonValueKind.Object) return [new("$", "expected a JSON object")];
var errors = new List<FieldError>(); foreach (var (field, chain) in Rules) { var value = data.TryGetProperty(field, out var v) ? v : default; // default = Undefined kind foreach (var (ok, message) in chain) { if (ok(value)) continue; errors.Add(new(field, message)); if (failFast) return errors; // 3. fail fast: stop at the first problem break; // collect all: move on to the next field } } // 4. cross-field rules run only once every field they read is known to be valid bool hasSeats = data.TryGetProperty("seats", out var s); bool enoughSeats = hasSeats && s.ValueKind == JsonValueKind.Number && s.TryGetInt32(out int n) && n >= 2; if (errors.Count == 0 && data.GetProperty("plan").GetString() == "team" && !enoughSeats) errors.Add(new("seats", "team plan needs at least 2 seats")); return errors; }}Notice the type check on age. In Python, True is an int (bool subclasses int), so isinstance(True, int) is True. In JavaScript, typeof NaN is "number". In C#, JsonElement.TryGetInt32 rejects 36.5 and true. Every language has a version of this trap.
Return a result instead of throwing
Section titled “Return a result instead of throwing”Some failures are expected: users mistype, files contain bad rows. Making them part of the return type forces callers to handle them, and makes the happy path explicit.
- The signature says the function can fail:
Result[Endpoint], notEndpoint. - The caller must branch on success or failure to get at the value. TypeScript and C# check this at compile time.
- Errors from inner steps pass upward unchanged (an early return), so the first problem wins.
- A successfully parsed value has a precise type. Code that receives an
Endpointnever re-checks the port.
from dataclasses import dataclassfrom typing import Generic, TypeVar
T = TypeVar("T")
@dataclass(frozen=True)class Ok(Generic[T]): value: T
@dataclass(frozen=True)class Err: error: str
Result = Ok[T] | Err # 1. failure is a value in the signature, not a hidden exception
@dataclass(frozen=True)class Endpoint: # 4. once parsed, the type itself says "already valid" host: str port: int
def parse_port(text: str) -> Result[int]: if not (text.isascii() and text.isdigit()): # isdigit alone accepts "²" and "٣" return Err(f"port {text!r} is not a number") port = int(text) return Ok(port) if 1 <= port <= 65535 else Err(f"port {port} out of range 1-65535")
def parse_endpoint(text: str) -> Result[Endpoint]: host, sep, port_text = text.rpartition(":") if not sep or not host: return Err(f"expected host:port, got {text!r}") match parse_port(port_text): # 2. the caller must look at both cases case Err() as e: return e # 3. pass the error up unchanged (early return) case Ok(port): return Ok(Endpoint(host, port)) raise AssertionError("unreachable")
if __name__ == "__main__": assert parse_endpoint("db.internal:5432") == Ok(Endpoint("db.internal", 5432)) assert parse_endpoint("db:0") == Err("port 0 out of range 1-65535") assert parse_endpoint("db:http") == Err("port 'http' is not a number") assert parse_endpoint("db:²") == Err("port '²' is not a number") assert parse_endpoint("5432") == Err("expected host:port, got '5432'") print("ok")import assert from "node:assert/strict";
// 1. failure is a value in the signature, not a hidden exceptiontype Result<T> = { ok: true; value: T } | { ok: false; error: string };const ok = <T>(value: T): Result<T> => ({ ok: true, value });const err = <T>(error: string): Result<T> => ({ ok: false, error });
interface Endpoint { readonly host: string; readonly port: number } // 4. parsed = valid
function parsePort(text: string): Result<number> { if (!/^[0-9]+$/.test(text)) return err(`port '${text}' is not a number`); // Number("") is 0, Number(" 8 ") is 8 const port = Number(text); return port >= 1 && port <= 65535 ? ok(port) : err(`port ${port} out of range 1-65535`);}
export function parseEndpoint(text: string): Result<Endpoint> { const at = text.lastIndexOf(":"); if (at <= 0) return err(`expected host:port, got '${text}'`); const port = parsePort(text.slice(at + 1)); if (!port.ok) return port; // 2-3. the compiler forces the check; pass the error up unchanged return ok({ host: text.slice(0, at), port: port.value }); // narrowed: port.value is a number here}
assert.deepEqual(parseEndpoint("db.internal:5432"), ok({ host: "db.internal", port: 5432 }));assert.deepEqual(parseEndpoint("db:0"), err("port 0 out of range 1-65535"));assert.deepEqual(parseEndpoint("db:http"), err("port 'http' is not a number"));assert.deepEqual(parseEndpoint("db:"), err("port '' is not a number"));assert.deepEqual(parseEndpoint("5432"), err("expected host:port, got '5432'"));console.log("ok");if (Parse.Endpoint("db.internal:5432") is not Ok<Endpoint>(var ep) || ep != new Endpoint("db.internal", 5432)) throw new Exception("valid");Expect(Parse.Endpoint("db:0"), "port 0 out of range 1-65535");Expect(Parse.Endpoint("db:http"), "port 'http' is not a number");Expect(Parse.Endpoint("db: 80"), "port ' 80' is not a number"); // int.Parse would accept the spaceExpect(Parse.Endpoint("5432"), "expected host:port, got '5432'");Console.WriteLine("ok");
static void Expect<T>(Result<T> r, string error){ if (r is not Err<T>(var e) || e != error) throw new Exception($"expected error '{error}', got {r}");}
// 1. failure is a value in the signature, not a hidden exceptionabstract record Result<T>;sealed record Ok<T>(T Value) : Result<T>;sealed record Err<T>(string Error) : Result<T>;
record Endpoint(string Host, int Port); // 4. once parsed, the type says "already valid"
static class Parse{ static Result<int> Port(string text) { if (text.Length == 0 || !text.All(char.IsAsciiDigit) || text.Length > 5) return new Err<int>($"port '{text}' is not a number"); int port = int.Parse(text); return port is >= 1 and <= 65535 ? new Ok<int>(port) : new Err<int>($"port {port} out of range 1-65535"); }
public static Result<Endpoint> Endpoint(string text) { int at = text.LastIndexOf(':'); if (at <= 0) return new Err<Endpoint>($"expected host:port, got '{text}'"); return Port(text[(at + 1)..]) switch // 2. a switch over both cases { Err<int>(var e) => new Err<Endpoint>(e), // 3. pass the error up Ok<int>(var port) => new Ok<Endpoint>(new(text[..at], port)), _ => throw new InvalidOperationException(), }; }}Exceptions or results, per language
Section titled “Exceptions or results, per language”| Python | TypeScript | C# | |
|---|---|---|---|
| Idiomatic default | Exceptions (“easier to ask forgiveness”): ValueError, TypeError, custom subclasses |
Exceptions exist, but a discriminated-union result ({ ok: true, value } | { ok: false, error }) is common for expected failures |
Exceptions for exceptional cases; the TryParse pattern (bool TryX(..., out T value)) for expected ones |
| Several errors at once | A list of error objects; or ExceptionGroup with except* (3.11+) |
An array of { path, message } |
A list of errors; ASP.NET Core returns ValidationProblemDetails |
| Guard clauses | if ...: raise ValueError(...) |
throw new RangeError(...) |
ArgumentNullException.ThrowIfNull(x), ArgumentOutOfRangeException.ThrowIfNegative(n) (.NET 8) |
| Type of a caught error | anything deriving BaseException |
unknown under strict (useUnknownInCatchVariables) |
Exception |
| Popular libraries | pydantic (collects every error) | zod (safeParse returns a result) |
FluentValidation, DataAnnotations |
A rule of thumb that works in all three: return errors that a caller is expected to handle (bad user input, a missing record), and throw for bugs and broken invariants (a negative balance that “can’t happen”, a null where the type said no null). In an interview, if the spec says “return None” or “return error:...”, that is the contract: never let an exception escape instead.
Good error messages
Section titled “Good error messages”- Where: a field path (
items[2].price), a row number, or a line and column. - What: a stable, machine-readable code (
out_of_range) plus a human message. - What would be valid: “must be between 13 and 130”, not “invalid age”.
- Never echo secrets (passwords, card numbers) back in the message.
- For HTTP APIs, RFC 9457 “Problem Details” defines a standard JSON error shape (
type,title,status,detail, plus extensions such as anerrorsarray).
What it costs
Section titled “What it costs”| Time | Notes | |
|---|---|---|
| Field-level checks | O(size of input) | Each field is looked at a constant number of times. |
| Fail fast | best case O(1) after parsing | Cheapest, but costs the user a round trip per error. |
| Collect all | O(size of input), always | Every field is checked even when the first one failed. |
| Stateful checks | one lookup each (DB, map) | Run them last, and only for otherwise-valid input. |
The real cost is not CPU. It’s ordering. Validating after you’ve started mutating state leaves half-applied changes when a later check fails. Check everything, then apply everything. If the change spans several records or services, use a transaction or design the operation to be idempotent.
Variations you’ll meet
Section titled “Variations you’ll meet”- Normalize, then validate. Trim whitespace, unify case where it’s meaningless (an email’s domain), apply Unicode NFC normalization. Do it before the checks, and store the normalized form, or duplicates slip through.
- Allow-list over deny-list. Say what is accepted (
[a-z0-9_]{3,20}) instead of listing what is forbidden. Deny-lists miss the case you didn’t think of. - Row-level validation for files. For CSV imports, validate each row independently, report
row N: messagelines in a deterministic order, and decide whether a bad row skips just itself or aborts the file. - Schema-driven validation. Describe the rules as data (a JSON Schema, a field→rules table like the samples) and write one generic engine. Adding a field becomes a one-line change.
- Validation in depth. The boundary check is the main one, but database constraints (
NOT NULL,UNIQUE,CHECK) are a second net that catches races, such as two signups with the same email at the same moment.
Pitfalls
Section titled “Pitfalls”- Partial mutation. Debiting the source account, then discovering the target doesn’t exist. Validate every precondition before the first write.
- Missing vs null vs empty.
{},{"name": null}and{"name": ""}are three different inputs. Decide what each means. - Lenient built-ins.
int(" 7 ")andint("1_000")succeed in Python;Number("")is0in JavaScript;int.Parsein C# accepts leading and trailing spaces. Validate the exact text if the format is strict. - Big numbers in JSON. JavaScript’s
JSON.parseturns12345678901234567890into a nearby float silently. IDs and money amounts beyond 2^53 should travel as strings. - Length means what?
"😀".lengthis 2 in JavaScript and C# (UTF-16 units) butlen("😀")is 1 in Python (code points). Say which you limit. - Over-strict email checks. Real addresses can contain
+, quotes and non-ASCII. Check the basics (one@, non-empty parts, a length cap of 254 in practice), then confirm by sending mail. - Swallowing errors.
except Exception: passor an emptycatch {}turns bad input into wrong output. Catch the specific error you expect.
In the interview
Section titled “In the interview”- Ask which inputs can be invalid and what each should produce. The underspecified error path is often the real test.
- Ask whether to stop at the first error or report all of them, and in what order messages should appear.
- Say “I’ll validate everything before changing any state” out loud. Then do it.
- Separate parsing from business logic in your code: one function turns text into typed values, the rest works on typed values.
- Test the edge cases before you’re asked: empty input, missing field, wrong type, boundary values (12, 13, 130, 131), duplicates, very long strings.
- Likely follow-ups: add a new rule without touching the others, report all errors instead of the first, make an import skip bad rows but count them, localize messages.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Validate a credit card number with the Luhn checksum, and report why it failed (length, non-digit, checksum).
- Check an IBAN: country-specific length, move the first four characters to the end, convert letters to numbers, and verify mod 97 equals 1.
- A password-policy checker that returns every failed rule (length, digit, symbol, not containing the username), in a fixed order.
- Validate a nested configuration object and report errors with paths like
servers[2].port. - Decide whether a partially filled 9×9 Sudoku board is valid, reporting the first conflicting row, column or box.
Go deeper
Section titled “Go deeper”- Alexis King, “Parse, don’t validate”: why returning a more precise type beats returning a boolean.
- OWASP, Input Validation Cheat Sheet: allow-lists, syntactic vs semantic validation, where to validate.
- RFC 9457: Problem Details for HTTP APIs, the standard error body, and RFC 9110 §15.5 for 400 vs 409 vs 422.
- Microsoft Learn, Best practices for exceptions, and the Python tutorial on errors and exceptions (including exception groups).