#38148Easy

JSONValue

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 },
  ],
}

Challenge Instructions: JSONValue

Easy

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).

Loading...

Detailed Explanation

The solution is a six-member union:

type JSONValue =
  | null
  | boolean
  | number
  | string
  | JSONValue[]
  | { [key: string]: JSONValue }

The four leaves

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.

The two recursive members

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 // false

One level works, two levels do not. Replacing Flat with JSONValue inside the array and the index signature is the entire fix.

How the tests interrogate the type

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
//   → true

A 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 leaf

The recursion in the check mirrors the recursion in the type. Each step strips one layer of nesting until only leaves remain.

Edge cases the tests cover

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 // true

Interfaces 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.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges