Skip to content

Instantly share code, notes, and snippets.

@Zezombye
Created November 26, 2025 02:32
Show Gist options
  • Select an option

  • Save Zezombye/eea0f075e517fe7aa363a273ddc1bf01 to your computer and use it in GitHub Desktop.

Select an option

Save Zezombye/eea0f075e517fe7aa363a273ddc1bf01 to your computer and use it in GitHub Desktop.
Javascript JSON validator
//Given a string, returns true if the string is the beginning of a valid JSON string.
//This function assumes the string can get cut off, such that "tru" is valid as it is the beginning of "true".
//Same for unclosed strings, arrays, and objects.
function jsonValidate(str) {
let i = 0;
const len = str.length;
const stack = []; // Tracks context: '{' or '['
// Tracks the last successfully parsed token to enforce grammar
// States: 'START', 'OBJ_OPEN', 'ARR_OPEN', 'KEY', 'COLON', 'VALUE', 'COMMA'
let lastToken = 'START';
let hasSeenNonWhitespace = false; //don't allow whitespace-only string
while (i < len) {
// 1. Skip Whitespace
if (/\s/.test(str[i])) {
i++;
continue;
}
hasSeenNonWhitespace = true;
const char = str[i];
const context = stack.length > 0 ? stack[stack.length - 1] : 'ROOT';
// 2. Handle Structural Characters
// Start Object
if (char === '{') {
// Valid contexts: Start, after Colon (in obj), or after Bracket/Comma (in array)
if (context === 'ROOT' && lastToken !== 'START') return false;
if (context === '{' && lastToken !== 'COLON') return false;
if (context === '[' && lastToken !== 'ARR_OPEN' && lastToken !== 'COMMA') return false;
stack.push('{');
lastToken = 'OBJ_OPEN';
i++;
continue;
}
// Start Array
if (char === '[') {
if (context === 'ROOT' && lastToken !== 'START') return false;
if (context === '{' && lastToken !== 'COLON') return false;
if (context === '[' && lastToken !== 'ARR_OPEN' && lastToken !== 'COMMA') return false;
stack.push('[');
lastToken = 'ARR_OPEN';
i++;
continue;
}
// End Object
if (char === '}') {
if (context !== '{') return false;
// Valid if object is empty OR if we just finished a value
if (lastToken !== 'OBJ_OPEN' && lastToken !== 'VALUE') return false;
stack.pop();
lastToken = 'VALUE'; // The completed object acts as a Value in the parent
i++;
continue;
}
// End Array
if (char === ']') {
if (context !== '[') return false;
// Valid if array is empty OR if we just finished a value
if (lastToken !== 'ARR_OPEN' && lastToken !== 'VALUE') return false;
stack.pop();
lastToken = 'VALUE';
i++;
continue;
}
// Colon
if (char === ':') {
if (context !== '{') return false;
if (lastToken !== 'KEY') return false; // Must follow a Key
lastToken = 'COLON';
i++;
continue;
}
// Comma
if (char === ',') {
if (context === 'ROOT') return false;
if (lastToken !== 'VALUE') return false; // Must follow a Value
lastToken = 'COMMA';
i++;
continue;
}
// 3. Handle Strings (Keys or Values)
if (char === '"') {
// Determine if this string is a Key or a Value based on context
let isKey = false;
if (context === '{') {
if (lastToken === 'OBJ_OPEN' || lastToken === 'COMMA') isKey = true;
else if (lastToken === 'COLON') isKey = false;
else return false; // e.g. "key" "key" missing colon
} else if (context === '[') {
if (lastToken === 'ARR_OPEN' || lastToken === 'COMMA') isKey = false;
else return false;
} else { // ROOT
if (lastToken === 'START') isKey = false;
else return false;
}
i++; // Skip opening quote
// Consume String
while (i < len) {
if (str[i] === '\\') {
i++;
if (i < len && '"/\\bfnrt'.includes(str[i])) {
// Valid escape, continue
i++;
} else if (i + 4 < len && str[i] === 'u' && /^[0-9a-fA-F]{4}$/.test(str.substring(i+1, i+5))) {
// Valid unicode escape
i += 4; // Skip the 4 hex digits
} else {
return false; // Invalid escape sequence
}
continue;
}
if (i < len && str[i] === '"') {
break; // Found closing quote
}
i++;
}
if (i >= len) return true; // Valid partial: string cut off
i++; // Skip closing quote
lastToken = isKey ? 'KEY' : 'VALUE';
continue;
}
// 4. Handle Numbers
if (char === '-' || char === "+" ||/[0-9]/.test(char)) {
if (context === 'ROOT' && lastToken !== 'START') return false;
if (context === '{' && lastToken !== 'COLON') return false;
if (context === '[' && lastToken !== 'ARR_OPEN' && lastToken !== 'COMMA') return false;
const start = i;
// Quick check for invalid leading zeros (e.g., 01, -01) which are not allowed in JSON
if (char === '0' && i + 1 < len && /[0-9]/.test(str[i+1])) return false;
if ((char === '-' || char === "+") && i + 1 < len && str[i+1] === '0' && i + 2 < len && /[0-9]/.test(str[i+2])) return false;
let hasSeenDot = false;
let lastCharWasDot = false;
let hasSeenDigit = false;
let canHavePlusOrMinus = true;
let hasSeenExp = false;
// Consume valid number characters greedily
while (i < len && /[0-9eE+\-\.]/.test(str[i])) {
if (str[i] === ".") {
if (hasSeenDot || hasSeenExp || !hasSeenDigit) return false; // Only one dot allowed, and not after exponent
hasSeenDot = true;
lastCharWasDot = true;
canHavePlusOrMinus = false;
} else if (/[0-9]/.test(str[i])) {
hasSeenDigit = true;
lastCharWasDot = false;
canHavePlusOrMinus = false;
} else if (/[eE]/.test(str[i])) {
if (hasSeenExp || !hasSeenDigit || lastCharWasDot) return false; // Only one exponent allowed, must follow digit
hasSeenExp = true;
hasSeenDigit = false; // Need digit after exponent
lastCharWasDot = false;
canHavePlusOrMinus = true; // Can have + or - after exponent
} else if (str[i] === '+' || str[i] === '-') {
// Sign only allowed immediately after exponent or start of number
if (!canHavePlusOrMinus) return false;
canHavePlusOrMinus = false;
lastCharWasDot = false;
} else {
lastCharWasDot = false;
}
i++;
}
// If we hit EOF while parsing number, it is valid partial (e.g., "1.", "1e", "-")
if (i >= len) return true;
// If we stopped due to a separator, strict check the formed number
const numStr = str.substring(start, i);
if (!/^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/.test(numStr)) return false;
lastToken = 'VALUE';
continue;
}
// 5. Handle Keywords (true, false, null)
if (['t', 'f', 'n'].includes(char)) {
if (context === 'ROOT' && lastToken !== 'START') return false;
if (context === '{' && lastToken !== 'COLON') return false;
if (context === '[' && lastToken !== 'ARR_OPEN' && lastToken !== 'COMMA') return false;
const target = char === 't' ? 'true' : (char === 'f' ? 'false' : 'null');
let j = 0;
while (j < target.length && i < len) {
if (str[i] !== target[j]) return false; // Char mismatch (e.g., "falze")
i++;
j++;
}
if (j < target.length) return true; // Valid partial (e.g., "tru", "fa")
lastToken = 'VALUE';
continue;
}
// Invalid Character
return false;
}
if (!hasSeenNonWhitespace) return false; // Don't allow whitespace-only string
// If loop finishes without error, the partial string is valid
return true;
}
let testSuite = {
[`{"a": "b", "c": ["d", true]}`]: true,
[`{"a": "b", "c": ["d", true]`]: true,
[`{"a": "b", "c": ["d", true]]`]: false,
[`{"a": "b", "c": ["d", true`]: true,
[`{"a": "b", "c": ["d", tru`]: true,
[`{"a": "b", "c": ["d", tr`]: true,
[`{"a": "b", "c": ["d", t`]: true,
[`{"a": "b", "c": ["d", tre`]: false,
[`{"a": "b", "c": ["d",`]: true,
[`{"a": "b", "c": ["d",,`]: false,
[`{"a": "b", "c": ["d"`]: true,
[`{"a": "b", "c": ["d":`]: false,
[`{"a": "b", "c": ["d`]: true,
[`{"a": "b", "c": ["`]: true,
[`{"a": "b", "c": [`]: true,
[`{"a": "b", "c":: [`]: false,
[`{"a": "b", "c": `]: true,
[`{"a": "b", "c"`]: true,
[`{"a": "b", "c`]: true,
[`{"a": "b", "`]: true,
[`{"a": "b", `]: true,
[`{"a": "b",, `]: false,
[`{"a": "b"`]: true,
[`{"a": "b`]: true,
[`{"a": "b\\"c`]: true,
[`{"a": "b\\"c""`]: false,
[`{"a": "b\\p`]: false,
[`{"a": "b\\b`]: true,
[`{"a": "b\\f`]: true,
[`{"a": "b\\n`]: true,
[`{"a": "b\\r`]: true,
[`{"a": "b\\t`]: true,
[`{"a": "b\\\\`]: true,
[`{"a": "b\\u12aB`]: true,
[`{"a": "b\\u12aBp`]: true,
[`{"a": "b\\u12ap`]: false,
[`{"a": "b\\N`]: false,
[`{"a": 1`]: true,
[`{"a": 1.`]: true,
[`{"a": 1.0e`]: true,
[`{"a": 1.0e+`]: true,
[`{"a": 1.0e+3`]: true,
[`{"a": 1.0e+3.`]: false,
[`{"a": 1.0e+3+`]: false,
[`{"a": 1.0e+3-`]: false,
[`{"a": 1.0e+3e`]: false,
[`{"a": 1.0e-3`]: true,
[`{"a": 1e-3`]: true,
[`{"a": 1..`]: false,
[`{"a": 1.2.`]: false,
[`{"a": -`]: true,
[`{"a": +`]: true,
[`{"a": -.`]: false,
[`{"a": +.`]: false,
[`{"a": +-`]: false,
[`{"a": -+`]: false,
[`{"a": ++`]: false,
[`{"a": --`]: false,
[`{"a": 1-`]: false,
[`{"a": 1+`]: false,
[`{"a": 1e3-`]: false,
[`{"a": 1e3+`]: false,
[`{"a": .`]: false,
[`{"a": -e4`]: false,
[`{"a": +e4`]: false,
[`"abc`]: true,
[`"abc""`]: false,
[`"abc"0`]: false,
[` "abc`]: true,
[` `]: false,
[``]: false,
[`0.`]: true,
[`t`]: true,
[`f`]: true,
[`n`]: true,
[`nul`]: true,
[`nulf`]: false,
[`nulll`]: false,
[`[[[`]: true,
[`[[[}`]: false,
}
let hasError = false;
for (let [input, expected] of Object.entries(testSuite)) {
let result = jsonValidate(input);
if (result !== expected) {
console.error(`Test failed for input: ${input}. Expected: ${expected}, but got: ${result}`);
hasError = true;
}
}
if (!hasError) {
console.log("All tests passed!");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment