Build a JSON Validator in TypeScript: From Parser Errors to Typed Data
A blueprint-first tutorial: validate JSON at the boundary, return typed results instead of throwing, and verify behaviour with small reproducible tests — no extra dependencies.
By SmokyCoders Editorial · Engineering
Editorial Engine sample. Core parse/format logic smoke-tested in Node; browser UI integration not tested.

Prerequisites
- Basic TypeScript (types, functions, generics helpful but not required)
- Node.js 20+ or a modern browser console to try the examples
- Familiarity with JSON objects, arrays, strings, numbers, booleans and null
What you will learn
- Explain the difference between JSON syntax validation and shape (schema) validation
- Build a validateJson function that returns a typed result instead of throwing
- Turn parser failures into actionable, user-safe error messages
- Verify the validator with a small table of passing and failing inputs
Key takeaways
- Parse once at the trust boundary, then pass typed data inward.
- Syntax-valid JSON is not automatically the shape your code needs.
- Return { ok: true, value } | { ok: false, error } so callers must handle failure.
- Never log full untrusted payloads — log shape and position, not secrets.
Your app receives JSON from an API, a file, or a text box — and somewhere deep inside, JSON.parse throws, a field is undefined, or a number arrives as a string. This tutorial builds one small, reliable habit: validate at the boundary and hand the rest of your code typed, trustworthy data.
Two different jobs share the word “validation”. Syntax validation asks: is this valid JSON grammar? Shape validation asks: does this value have the keys and types I need? JSON.parse does only the first.
Blueprint: what we are building
Blueprint — har box ek component hai, har arrow data ka flow. Red path = failure result, green path = aage ka flow.
| Component | Responsibility | Failure it prevents |
|---|---|---|
| parseJson | Syntax only: text → unknown | Crashes from malformed JSON |
| isRecord / guards | Runtime shape checks | undefined field access |
| validateUser (example) | Domain shape: required keys + types | Bad data reaching business logic |
| Result type | Forces callers to handle failure | Ignored errors |
Project shape
json-validator/
src/
validate.ts # parse + result type (this article)
user.ts # example shape guard for a User payload
test/
validate.test.ts # table-driven checks (manual or your runner)
package.json
tsconfig.jsonPrerequisites: Node.js 20+ (or a browser console) and TypeScript basics. No runtime dependencies — JSON.parse and JSON.stringify are built into every modern JavaScript engine (RFC 8259 defines the JSON grammar they implement).
Step 1 — A result type, not a throw
A discriminated union forces the caller to check ok before touching value. That single check eliminates a whole class of “forgot to handle the error” bugs.
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
export function parseJson(text: string): Result<unknown> {
const trimmed = text.trim();
if (!trimmed) return { ok: false, error: "Enter JSON to validate." };
try {
return { ok: true, value: JSON.parse(trimmed) as unknown };
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid JSON.";
return { ok: false, error: `Invalid JSON: ${message}` };
}
}Line by line: trim rejects empty input early; try/catch converts the SyntaxError that JSON.parse throws into data; `as unknown` is honest — at this point we know it parsed, not what shape it is.
Step 2 — Shape checks with type guards
export function isRecord(
value: unknown,
): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export type User = { id: number; name: string };
export function validateUser(value: unknown): Result<User> {
if (!isRecord(value))
return { ok: false, error: "Expected a JSON object." };
if (typeof value.id !== "number" || !Number.isFinite(value.id))
return { ok: false, error: "Field 'id' must be a finite number." };
if (typeof value.name !== "string" || !value.name.trim())
return { ok: false, error: "Field 'name' must be a non-empty string." };
return { ok: true, value: { id: value.id, name: value.name } };
}
export function parseUserJson(text: string): Result<User> {
const parsed = parseJson(text);
if (!parsed.ok) return parsed;
return validateUser(parsed.value);
}Security note: return only the fields you validated ({ id, name } above). Passing the original object through lets unexpected or dangerous keys leak into later code.
Step 3 — Verify with a truth table
| Input | parseUserJson result | Why |
|---|---|---|
| {"id":1,"name":"Ada"} | ok: User | Syntax + shape pass |
| {"id":1,} | ok: false (Invalid JSON) | Trailing comma is not JSON |
| {"id":"1","name":"Ada"} | ok: false (id must be a number) | Syntax ok, shape fails |
| [] | ok: false (Expected a JSON object) | Array is not a record |
| ok: false (Enter JSON…) | Empty input rejected early |
const cases: Array<[string, boolean]> = [
['{"id":1,"name":"Ada"}', true],
['{"id":1,}', false],
['{"id":"1","name":"Ada"}', false],
["[]", false],
["", false],
];
for (const [input, shouldPass] of cases) {
const result = parseUserJson(input);
console.log(input || "(empty)", "→", result.ok, result.ok ? result.value : result.error);
if (result.ok !== shouldPass) throw new Error(`Unexpected result for: ${input}`);
}
console.log("All cases behaved as expected.");Expected output: five lines ending with “All cases behaved as expected.” Exact parser wording after “Invalid JSON:” varies by engine — assert on ok and your own messages, never on engine text.
Common mistakes and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Works in tests, fails on real API data | Real payload shape differs (nulls, string numbers) | Log shape (keys + types), not full payload; extend guards |
| Cannot read properties of undefined | Trusted parsed JSON without shape checks | Validate at the boundary; pass only typed values inward |
| Error message leaks payload in UI/logs | Dumped raw input into the error | Show position/shape only; keep raw payloads out of logs |
| TypeScript says value is any and stays silent | JSON.parse result assigned without unknown | Type it as unknown immediately, then narrow with guards |
Try it in the Developer Lab
Paste the failing rows from the truth table into the SmokyCoders JSON tool (linked below) and watch syntax errors surface before any shape logic runs. Processing there stays in your browser — a useful model for boundary validation in your own UI.
Conclusion and next steps
You built a dependency-free JSON boundary: parse once, return a result, guard the shape, and verify with a truth table. Next steps: add guards for nested objects, collect multiple field errors instead of returning the first, and — when schemas grow — evaluate a dedicated schema library rather than hand-rolling everything.
Frequently asked questions
Is JSON.parse enough validation?
No. JSON.parse checks syntax only. You still need shape checks (required keys, types, ranges) before trusting the data in business logic.
Should I throw or return a result object?
At a UI or API boundary, returning a result object is usually clearer: callers must handle failure explicitly and you can show a safe message without a stack trace.
Can I validate huge JSON files with JSON.parse?
JSON.parse loads the whole value into memory. For very large or streamed payloads you need a streaming parser — out of scope for this beginner blueprint.
References and further reading
- MDN — JSON.parse() — JSON.parse parses a JSON string and throws SyntaxError on invalid input. [unverified]
- RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format — Defines the JSON grammar (objects, arrays, strings, numbers, true/false/null). [unverified]
- TypeScript Handbook — Narrowing — Type narrowing lets TypeScript refine unknown values after runtime checks. [unverified]