Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool
Debugging Custom JSON Serializers and Deserializers
Working with JSON is fundamental in modern web development, especially when building APIs or storing structured data. While the built-in JSON.stringify
and JSON.parse
methods handle most basic cases, you often encounter scenarios requiring custom logic. This might involve serializing complex objects with methods, handling specific data types like Date
or BigInt
, dealing with circular references, or implementing specific data formats.
Implementing custom serialization (converting an object to a JSON string) and deserialization (converting a JSON string back to an object) can introduce subtle bugs. This article explores common issues and effective strategies for debugging them.
Why Custom Logic?
You might need custom JSON handling for reasons like:
- Serializing instances of custom classes (methods are lost by default).
- Handling data types not natively supported by JSON (e.g.,
Date
,Set
,Map
,BigInt
). - Controlling which properties are included or excluded.
- Formatting output (indentation, sorting keys).
- Implementing specific data format versions or transformations.
- Preventing circular reference errors.
Common Serialization Debugging Challenges
1. Data Type Mismatches & Loss
By default, JSON.stringify
transforms certain types:
Date
objects become ISO 8601 strings.Set
andMap
become empty objects {}.BigInt
throws aTypeError
.undefined
, functions, and Symbols in objects are omitted.undefined
, functions, and Symbols in arrays becomenull
.
If your custom logic doesn't handle these transformations explicitly, you might lose data or encounter unexpected formats.
Example: BigInt Error
const data = {
id: 1n, // BigInt
name: "Example"
};
try {
// This will throw a TypeError
JSON.stringify(data);
} catch (error: any) {
console.error("Serialization error:", error.message);
// Output: Serialization error: Do not know how to serialize a BigInt
}
// Custom serialization needed for BigInt
const customData = {
id: 1n,
value: 123,
};
const customJsonString = JSON.stringify(customData, (key, value) => {
if (typeof value === 'bigint') {
// Convert BigInt to string (or number if within safe range)
return value.toString();
}
return value; // Return everything else unchanged
});
console.log(customJsonString);
// Output: {"id":"1","value":123}
Debugging Tip: Use console.log
on the object *before* serialization and inspect the resulting JSON string. Pay close attention to the types and presence of properties.
2. Circular References
If an object contains a reference back to itself, directly or indirectly, JSON.stringify
will throw a TypeError
.
Example: Circular Reference Error
const parent: any = { name: "Parent" };
const child: any = { name: "Child", parent: parent };
parent.child = child; // Create circular reference
try {
// This will throw a TypeError
JSON.stringify(parent);
} catch (error: any) {
console.error("Serialization error:", error.message);
// Output: Serialization error: Converting circular structure to JSON...
}
// Custom serialization needed to handle circular references
const visited = new Set(); // Keep track of visited objects
const safeJsonString = JSON.stringify(parent, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (visited.has(value)) {
// Circular reference found, discard it
return; // Return undefined to omit the property
}
// Otherwise, add to the set and continue traversal
visited.add(value);
}
return value;
});
console.log(safeJsonString);
// Output might vary slightly but will omit the circular part: {"name":"Parent","child":{"name":"Child"}}
// Note: The 'parent' property on 'child' is dropped because 'parent' was already visited.
Debugging Tip: The error message "Converting circular structure to JSON" is a dead giveaway. Analyze your object graph to find the loop. Logging object structures can help visualize relationships.
3. Handling undefined
vs null
JSON.stringify
treats undefined
and null
differently depending on whether they are object properties or array elements.
Example: Undefined/Null Differences
const data = {
prop1: "hello",
prop2: undefined, // Will be omitted
prop3: null // Will be included as null
};
const arrayData = ["item1", undefined, null, "item2"]; // undefined becomes null
console.log("Object:", JSON.stringify(data));
// Output: {"prop1":"hello","prop3":null}
console.log("Array:", JSON.stringify(arrayData));
// Output: ["item1",null,null,"item2"]
If your deserialization logic expects a specific structure (e.g., a field to be present even if its value is "empty"), the omission of undefined
properties can cause issues. Custom logic using a replacer function might be needed to explicitly include or transform undefined
values if required.
Common Deserialization Debugging Challenges
1. Missing or Unexpected Fields/Types
When deserializing JSON, the input might not match the expected structure or data types. This is especially common when dealing with external APIs or evolving data schemas.
- A required field might be missing.
- A field might have an unexpected data type (e.g., a string instead of a number).
- Extra, unexpected fields might be present.
Example: Handling Unexpected Input
interface ExpectedData {
id: number;
name: string;
createdAt: Date;
}
const jsonStringGood = '{"id": 123, "name": "Test", "createdAt": "2023-10-27T10:00:00.000Z"}';
const jsonStringBadMissing = '{"id": 456, "createdAt": "2023-10-27T10:00:00.000Z"}'; // missing name
const jsonStringBadType = '{"id": "789", "name": "Another", "createdAt": "2023-10-27T10:00:00.000Z"}'; // id is string
// Basic parse - doesn't validate or convert Date
const parsedGood = JSON.parse(jsonStringGood);
console.log("Parsed Good (no Date conversion):", parsedGood);
console.log("Parsed Good Type of createdAt:", typeof parsedGood.createdAt); // string
try {
// Missing field - accessing name will be 'undefined' but no parse error
const parsedBadMissing: ExpectedData = JSON.parse(jsonStringBadMissing);
console.log("Parsed Bad Missing (no error):", parsedBadMissing);
console.log("Accessing missing name:", parsedBadMissing.name); // undefined
} catch (error: any) {
console.error("This won't catch missing fields directly:", error.message);
}
try {
// Wrong type - accessing id will be a string, no parse error
const parsedBadType: ExpectedData = JSON.parse(jsonStringBadType);
console.log("Parsed Bad Type (no error):", parsedBadType);
console.log("Accessing wrong type id:", parsedBadType.id); // "789" (string)
} catch (error: any) {
console.error("This won't catch type mismatches directly:", error.message);
}
// Using a reviver to handle Date and check types (basic)
const parsedWithReviver: ExpectedData = JSON.parse(jsonStringGood, (key, value) => {
if (key === 'createdAt' && typeof value === 'string') {
const date = new Date(value);
// Basic check if it's a valid date
if (!isNaN(date.getTime())) {
return date;
}
}
// More robust checks would be needed for other types/missing fields
return value;
});
console.log("Parsed with Reviver (Date converted):", parsedWithReviver);
console.log("Parsed with Reviver Type of createdAt:", typeof parsedWithReviver.createdAt); // object (Date)
Debugging Tip: Check the structure and types of the incoming JSON string carefully. Use logging after JSON.parse
to see the resulting object's structure and values. Schema validation libraries are highly recommended for robust deserialization.
2. Security (Prototype Pollution)
Directly deserializing untrusted user input into object structures without validation can be a security risk, particularly related to prototype pollution attacks. If your custom deserialization logic (or a library you use) isn't careful, an attacker could inject properties like __proto__
or constructor.prototype
to modify the prototype of core JavaScript objects, potentially leading to arbitrary code execution or denial-of-service.
Conceptual Example: Prototype Pollution Risk
// !!! WARNING: This is a simplified example of the *vulnerability pattern*
// Do NOT use simple recursive assignment for deserialization from untrusted sources.
function unsafeAssign(obj: any, path: string[], value: any) {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (!current[key] || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[path[path.length - 1]] = value;
}
// Attacker controlled input might look like this:
// { "__proto__": { "isAdmin": true } }
// Or if using a library that processes dotted paths:
// { "constructor.prototype.isAdmin": true }
// If a function like unsafeAssign is used recursively to build an object
// from untrusted JSON without sanitization, it could potentially set
// 'isAdmin' on Object.prototype, affecting ALL objects.
// Example JSON payload (conceptual): { "user": { "__proto__": { "isAdmin": true } } }
/*
// Example of potential exploit if vulnerable assignment is used after parse:
const maliciousJson = `{"__proto__": {"isAdmin": true}}`;
const parsed = JSON.parse(maliciousJson);
// If parsed object is then processed by vulnerable assignment logic:
// unsafeAssign({}, Object.keys(parsed)[0].split('.'), Object.values(parsed)[0]); // Conceptual
// Now, any object might inherit isAdmin = true:
// const user = {};
// console.log(user.isAdmin); // Could output 'true' if prototype is polluted
*/
// Safe deserialization requires careful handling:
// - Avoid recursive merging/assignment logic if not explicitly sanitizing keys.
// - Use libraries specifically designed for safe data transformation (e.g., deepmerge with options to prevent __proto__).
// - Validate schema *before* using the data.
// - Sanitize input keys (e.g., disallow keys like __proto__).
Debugging Tip: Always treat external JSON as untrusted. If you are implementing custom deserialization logic (beyond a simple JSON.parse
with a reviver), be extremely cautious about how keys and values are assigned, especially in nested structures. Rely on established, security-audited libraries for complex transformations from untrusted sources.
3. Versioning and Backward Compatibility
Over time, your data structure might change. Older JSON strings might not conform to the latest object structure expected by your code. Deserialization logic needs to handle these variations gracefully.
Debugging Tip: Test your deserialization logic with various versions of your JSON schema. Implement conditional logic in your reviver or post-processing steps to handle older formats (e.g., providing default values for missing fields, converting deprecated field names).
Effective Debugging Strategies
1. Liberal Logging
Log the input object before serialization and the resulting string. Log the input string before deserialization and the resulting object. This helps pinpoint where the data transformation goes wrong. Inside custom replacer or reviver functions, log the key
and value
being processed.
Example: Logging in Reviver
const jsonString = '{"id": 123, "date": "2023-10-27T10:00:00.000Z", "value": 456}';
JSON.parse(jsonString, (key, value) => {
console.log(`Processing key: "${key}", value: `, value);
// Add your custom logic here
if (key === 'date' && typeof value === 'string') {
console.log("Converting date string...");
return new Date(value);
}
return value;
});
/*
Expected console output:
Processing key: "", value: { id: 123, date: '2023-10-27T10:00:00.000Z', value: 456 } // Initial call with root object
Processing key: "id", value: 123
Processing key: "date", value: 2023-10-27T10:00:00.000Z
Converting date string...
Processing key: "value", value: 456
*/
2. Leverage JSON.stringify
Options
The replacer
argument (a function or array of strings/numbers) allows you to control which properties are included and how their values are transformed during serialization. The space
argument (a string or number) helps format the output for readability, making it easier to inspect.
Example: Replacer and Space
const complexData = {
id: 123,
name: "Complex Item",
details: { price: 99.99, stock: 50 },
privateInfo: "secret", // Want to omit
createdAt: new Date(),
items: [ { code: "A" }, { code: "B" } ]
};
// Using a replacer function
const serializedWithReplacer = JSON.stringify(complexData, (key, value) => {
if (key === 'privateInfo') {
return; // Omit this property
}
if (value instanceof Date) {
return value.toISOString(); // Explicitly format Date
}
return value;
}, 2); // Use 2 spaces for indentation
console.log(serializedWithReplacer);
/*
Expected output (approximately):
{
"id": 123,
"name": "Complex Item",
"details": {
"price": 99.99,
"stock": 50
},
"createdAt": "YYYY-MM-DDTHH:mm:ss.sssZ", // ISO string
"items": [
{
"code": "A"
},
{
"code": "B"
}
]
}
*/
// Using an array replacer to only include specific keys
const serializedWithKeyArray = JSON.stringify(complexData, ['id', 'name', 'items'], 2);
console.log(serializedWithKeyArray);
/*
Expected output:
{
"id": 123,
"name": "Complex Item",
"items": [
{
"code": "A"
},
{
"code": "B"
}
]
}
*/
3. Leverage JSON.parse
Reviver
The reviver
function passed to JSON.parse
is called for every key-value pair in the parsed object (starting from the innermost nested levels and working outwards), including the root object itself. This is the primary mechanism for performing custom transformations during deserialization, such as converting date strings back to Date
objects.
Example: Reviver for Date Conversion
const jsonString = '{"name": "Event", "eventDate": "2024-01-15T09:30:00.000Z", "details": {"reportedAt": "2024-01-14T20:00:00.000Z"}}';
const parsedObject = JSON.parse(jsonString, (key, value) => {
// Check if the value is a string and looks like an ISO date string
if (typeof value === 'string') {
const dateMatch = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.exec(value);
if (dateMatch) {
const date = new Date(value);
// Optional: check if the date is valid
if (!isNaN(date.getTime())) {
return date;
}
}
}
return value; // Return the value unchanged if not a date string
});
console.log("Parsed object:", parsedObject);
console.log("Type of eventDate:", typeof parsedObject.eventDate); // object (Date)
console.log("Type of details.reportedAt:", typeof parsedObject.details.reportedAt); // object (Date)
Debugging Tip: Use the reviver to intercept values. Log the key
and value
to understand the parsing flow. If a value isn't converting as expected, check its type and format within the reviver function.
4. Schema Validation
For robust applications, especially when dealing with external data, validate the structure and types of the parsed JSON against a predefined schema. Libraries like Zod, Yup, or Joi provide powerful and expressive ways to define schemas and validate data. They offer clear error reporting when validation fails.
Conceptual Example: Zod Validation
// import { z } from 'zod'; // Requires zod library
/*
// Define your expected schema
const DataSchema = z.object({
id: z.number(),
name: z.string(),
createdAt: z.string().datetime().transform((str) => new Date(str)), // Validate and transform
optionalField: z.string().optional().nullable(), // Handle optional/nullable
});
const jsonStringGood = '{"id": 123, "name": "Test", "createdAt": "2023-10-27T10:00:00.000Z", "optionalField": null}';
const jsonStringBadMissing = '{"id": 456, "createdAt": "2023-10-27T10:00:00.000Z"}'; // missing name
const jsonStringBadType = '{"id": "789", "name": "Another", "createdAt": "2023-10-27T10:00:00.000Z"}'; // id is string
try {
const parsed = JSON.parse(jsonStringGood);
const validatedData = DataSchema.parse(parsed); // Validate and transform
console.log("Validated good data:", validatedData);
console.log("Type of validatedData.createdAt:", typeof validatedData.createdAt); // object (Date)
} catch (error: any) {
console.error("Validation error (good data unexpected):", error.message); // Should not happen for good data
}
try {
const parsed = JSON.parse(jsonStringBadMissing);
const validatedData = DataSchema.parse(parsed); // Will throw ZodError
console.log("Validated bad data (missing):", validatedData);
} catch (error: any) {
console.error("Validation error (missing field):", error.errors); // Zod provides detailed errors
}
try {
const parsed = JSON.parse(jsonStringBadType);
const validatedData = DataSchema.parse(parsed); // Will throw ZodError
console.log("Validated bad data (type):", validatedData);
} catch (error: any) {
console.error("Validation error (wrong type):", error.errors); // Zod provides detailed errors
}
*/
// Note: Actual Zod/Yup usage requires installation. This is conceptual.
Debugging Tip: Implement schema validation early in your deserialization pipeline. The detailed error messages from validation libraries are invaluable for identifying exactly what part of the input JSON doesn't match the expected structure or type.
5. Write Unit Tests
Write tests for your serialization and deserialization functions with various inputs: typical data, edge cases (empty arrays/objects, null values), incorrect data types, missing fields, circular references (if you handle them), and old versions of your data structure. Automated tests catch regressions as your code evolves.
6. Step-Through Debugging
Use your IDE's debugger to step through your custom serialization or deserialization logic (especially within replacer or reviver functions). This allows you to inspect the state of variables, the value of key
and value
at each step, and the execution flow.
Conclusion
Debugging custom JSON serializers and deserializers often boils down to carefully inspecting the data at different stages of the conversion process. Understanding how JSON.stringify
and JSON.parse
handle different data types by default is crucial. Leveraging the replacer
and reviver
functions, coupled with liberal logging, step-through debugging, and robust schema validation, provides a powerful toolkit for identifying and resolving issues, ensuring your data is correctly transformed and your application remains secure and reliable.
Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool