Parse JSON Safely in TypeScript
JSON.parse returns any, which bypasses TypeScript's type checking. Learn how to parse JSON safely by combining runtime validation with typed parsing so you never trust raw data from a string.
JSON.parse converts a string into a JavaScript value. In TypeScript, its return type is any. This means the compiler applies no checks to the result.
You can assign it to any variable, access any property, and call any method without a single compiler error. There is no guarantee the data actually matches your expectations.
Safe JSON parsing means wrapping the call with validation so the result has a known, trusted type before the rest of your code touches it.
The Problem with any
Because it returns any, a type annotation on the result is just a promise to the compiler, not a checked fact. Here is what the unsafe version looks like.
interface User {
id: number;
name: string;
}
const raw = '{"id": 1, "name": "Alice"}';
const user: User = JSON.parse(raw);
console.log(user.name.toUpperCase());This compiles and runs fine with valid input. But if the JSON string changes, say a missing field or a number where a string is expected, the type annotation does nothing to protect you.
The compiler trusted the assertion. The runtime crash happens somewhere far from the parse call, where the actual cause is harder to trace.
The fix is to validate, not assert.
A Safe Parsing Wrapper
Start with a function type that handles the two things that can go wrong when parsing: a syntax error from malformed JSON, and data that parses but does not match the expected shape.
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };The function itself takes the raw string and a validation function, then returns that result type instead of throwing, so the caller decides how to react to each failure.
type Validator<T> = (value: unknown) => value is T;
function safeJsonParse<T>(text: string, validate: Validator<T>): ParseResult<T> {
try {
const parsed: unknown = JSON.parse(text);
if (!validate(parsed)) {
return { ok: false, error: "Data does not match expected shape" };
}
return { ok: true, value: parsed };
} catch {
return { ok: false, error: "Invalid JSON string" };
}
}It catches the syntax error from malformed JSON and returns a failure. If the JSON is syntactically valid, it runs the validator, and only when both steps pass does it return a success with the typed value.
Use it with any type guard. Here is the shape a product should match.
interface Product {
sku: string;
price: number;
}The guard checks both fields at runtime before TypeScript will treat a value as a Product, so a JSON object missing either field is rejected instead of silently passing through.
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"sku" in value &&
typeof (value as Record<string, unknown>).sku === "string" &&
"price" in value &&
typeof (value as Record<string, unknown>).price === "number"
);
}Pass the guard as the second argument to the wrapper, then branch on the result the same way every time.
const result = safeJsonParse('{"sku": "A123", "price": 29.99}', isProduct);
if (result.ok) {
console.log(result.value.price);
} else {
console.error(result.error);
}The caller checks the ok field to know whether parsing succeeded. On failure, the error message says whether the JSON was malformed or the shape was wrong. On success, the value is a fully typed Product.
Step-by-Step Parsing Flow
The diagram shows the two independent checks. A malformed string is a syntax error caught by try/catch. A valid JSON value that does not match the shape is caught by the validator instead.
Only when both checks pass do you get a typed result back.
Parsing Arrays of Objects
JSON payloads are often arrays of objects. The same pattern applies: parse to an unknown value, then validate every element.
function isProductArray(value: unknown): value is Product[] {
return Array.isArray(value) && value.every(isProduct);
}
const result = safeJsonParse(
'[{"sku": "A", "price": 10}, {"sku": "B", "price": 20}]',
isProductArray
);
if (result.ok) {
result.value.forEach((product) => {
console.log(`${product.sku}: $${product.price}`);
});
}The array check confirms the top-level type, and the every call validates each element against the same guard used above.
If any element fails the validator, the whole array is rejected. This is the safe default for structured data, since partial success is rarely meaningful.
Parsing with Zod
For complex schemas, writing type guards by hand is tedious and error-prone. Zod lets you define the schema once and get both the validator and the TypeScript type.
import { z } from "zod";
const ProductSchema = z.object({
sku: z.string(),
price: z.number().positive(),
});
type Product = z.infer<typeof ProductSchema>;
function parseProductJson(raw: string): Product {
const parsed = JSON.parse(raw);
return ProductSchema.parse(parsed);
}Calling parse on the schema throws a detailed error if validation fails, including which field was wrong and why. This is more informative than a hand-written guard that returns false with no explanation.
For production code, use safeParse instead to avoid throwing.
function parseProductJson(raw: string): Product | null {
try {
const parsed = JSON.parse(raw);
const result = ProductSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch {
return null;
}
}safeParse returns a result object instead of throwing. Combined with the try/catch around the parse call itself, this function handles every failure path and never lets an untyped value escape.
Avoiding Common Mistakes
The most tempting shortcut is the type assertion.
const user = JSON.parse(raw) as User;This silences the compiler but provides zero runtime safety. If the JSON does not match the User shape, the error surfaces later as a confusing runtime failure, far from the actual cause.
Another mistake is validating too late or in too many places.
const data = JSON.parse(raw);
const name = (data as any).name as string;
const age = (data as any).age as number;Each property access is a separate assertion with no coordination. Validate the entire object once at the parsing boundary, then use the typed result everywhere.
For more on building validation into your application flow, see runtime validation in TypeScript. For the pattern used in the safe wrapper, see Result pattern in TypeScript.
Rune AI
Key Insights
- JSON.parse returns any, bypassing TypeScript's type checking completely.
- Never use as Type assertions on JSON.parse results without validation.
- Wrap JSON.parse in try/catch to handle SyntaxError from malformed input.
- Validate the parsed value with a type guard or schema before using it.
- Use a library like Zod for complex schemas; write guards by hand for simple ones.
Frequently Asked Questions
Why does JSON.parse return any in TypeScript?
Should I use a type assertion after JSON.parse?
How do I handle JSON.parse errors gracefully?
Conclusion
JSON.parse returns any because the compiler cannot know what is in a string at compile time. The safe path is to validate the parsed result before treating it as a known type. Whether you use Zod, a hand-written guard, or a lightweight wrapper, the principle is the same: parse, validate, then trust.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.