Build a recursive union type that models every value JSON.parse can return, and learn why an object type is accepted where an interface of the same shape is not.
JSONValue describes anything that can come back from JSON.parse: a string, a number, a boolean, null, an array of those, or an object whose values are again any of those. The list of shapes is short, but it folds back into itself, and that self-reference is the whole exercise. You write a type alias that mentions its own name.
Every object in this example has to typecheck against it, nested arrays and all:
const valid: JSONValue = {
name: 'Alice',
age: 30,
active: true,
scores: [1, 2, 3],
address: {
city: 'Wonderland',
zip: null,
coordinates: { lat: 51.5, lng: -0.1 },
},
friends: [
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 28 },
],
}Create a type JSONValue that represents all possible JSON values.
For example:
const valid: JSONValue = {
name: 'Alice',
age: 30,
active: true,
scores: [1, 2, 3],
address: {
city: 'Wonderland',
zip: null,
coordinates: { lat: 51.5, lng: -0.1 },
},
friends: [
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 28 },
],
}View on GitHub: https://tsch.js.org/38148
Change the following code to make the test cases pass (no type check errors).
The solution is a six-member union:
type JSONValue =
| null
| boolean
| number
| string
| JSONValue[]
| { [key: string]: JSONValue }null, boolean, number and string are the values JSON can hold without any nesting. They carry no reference back to JSONValue, so every recursion eventually lands on one of them. Note what is missing: undefined, symbol and function types have no JSON representation, and leaving them out of the union is what makes the failing test cases fail.
JSONValue[] says an array is valid when its element type is valid, and { [key: string]: JSONValue } says an object is valid when every property value is valid. Both refer to the alias being defined. TypeScript allows that as long as the reference sits inside an array element or an object property, because it can defer resolving those positions until they are actually used. A direct self-reference such as type Bad = Bad | string is rejected instead.
Skipping the recursion is the most common first attempt, and it gets you further than you would expect:
type Flat = null | boolean | number | string
type Naive = Flat | Flat[] | { [key: string]: Flat }
type A = { key: string } extends Naive ? true : false // true
type B = { scores: number[] } extends Naive ? true : false // falseOne level works, two levels do not. Replacing Flat with JSONValue inside the array and the index signature is the entire fix.
The test file never assigns a value. It asks a question about assignability:
[object Object]Because T is a bare type parameter on the left of extends, this conditional distributes: a union passed in is split, each member is checked on its own, and the results are joined back into a union. That matters for the boolean case, since boolean is internally true | false:
// IsValidJSON<boolean>
// → (true extends JSONValue ? true : false) | (false extends JSONValue ? true : false)
// → true | true
// → trueA nested object is checked structurally, one layer at a time:
// IsValidJSON<{ friends: { name: string }[] }>
// { friends: ... } is matched against { [key: string]: JSONValue }
// → is { name: string }[] a JSONValue? yes, via JSONValue[]
// → is { name: string } a JSONValue? yes, via the index signature
// → is string a JSONValue? yes, a leafThe recursion in the check mirrors the recursion in the type. Each step strips one layer of nesting until only leaves remain.
IsValidJSON<undefined> and IsValidJSON<symbol> are false: neither appears in the union, and under strict mode undefined is not assignable to anything else in it.IsValidJSON<() => void> is false. A function type has a call signature but no string index signature, so it does not match the object member.IsValidJSON<{ key: undefined }> and IsValidJSON<{ a: { b: undefined } }> are false. The index signature is checked against every property value at every depth, so one bad leaf anywhere sinks the whole object.IsValidJSON<[undefined]> is false for the same reason on the array side: [undefined] would have to satisfy JSONValue[], and undefined does not.IsValidJSON<number[][]> is true and shows the array member recursing into itself rather than into the object member.One case the tests do not cover is worth knowing about. An anonymous object type such as { key: string } gets an implicit index signature, which is why it matches { [key: string]: JSONValue }. A named interface does not:
interface Point {
x: number
}
type C = Point extends JSONValue ? true : false // false
type D = { x: number } extends JSONValue ? true : false // trueInterfaces can be merged by later declarations, so TypeScript refuses to assume it has seen all their properties. If you plan to use a JSONValue type in real code, expect to hit this the first time someone passes you an interface.
This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges