JSON to TypeScript, 4 different approaches

| 8 min read

As a frontend developer, you typically fetch data from a backend. The data comes in as a JSON object. But you’re using TypeScript, and now you have to turn the unstructured JSON into your TypeScript interfaces. TypeScript adds type syntax to JavaScript. There are various ways to convert JSON to TypeScript, and this article goes over them and explains how they work and what the pros and cons are.

Let us look at the following example, where we fetch data:

const request = await fetch('users.json')
const data = request.json()

And receive the following JSON document, with a list with users:

[
  {
    "id": "1042",
    "name": "Joe",
    "age": 27,
    "scores": [31.4, 29.9, 35.7]
  },
  {
    "id": "1071",
    "name": "Sarah",
    "age": 29,
    "scores": [25.0, 27.1]
  }
]

When receiving the data from the backend, the data does not have a type, it is basically “unknown”. We will see how we can turn this into the following TypeScript interface of the User:

// file: User.ts
export interface User {
  id: string
  name: string
  age: number
  scores: number[]
}

In short: you have to cast the data to your TypeScript object, and optionally, validate the data structure (which is recommended). There are various practical approaches to do this.

1. Type-cast without validation

The simplest solution is to just type-cast the data to your model, by casting the data to a list with users, User[]:

import type { User } from './User'

export async function getUsers(): Promise<User[]> {
  const request = await fetch('users.json')
  const data = await request.json()
  return data
}

In the TypeScript world, everything looks shiny and perfect now. However, we have made a big assumption: we just blindly say that the received data is a list with users, but we did not verify this! This is one of the big misconceptions people new to TypeScript can have. Instead of casting to User[], you can cast to an arbitrary interface like say Product[], and TypeScript will be completely happy. It does not make sense of course, and this will break when running your application and actually trying to use fields of your data.

2. Type-cast with validation

To know for sure that we received a list with users, we have to validate whether the received data actually has the expected structure. We can do that by writing validation functions, and use the validation right after fetching the data:

import type { User } from './User'

export function isUser(value: unknown): value is User {
  if (!value || typeof value !== 'object') {
    return false
  }
  const object = value as Record<string, unknown>

  return (
    typeof object.id === 'string' &&
    typeof object.name === 'string' &&
    typeof object.age === 'number' &&
    Array.isArray(object.scores) &&
    object.scores.every((score) => typeof score === 'number')
  )
}

export function isUsersArray(value: unknown): value is User[] {
  return Array.isArray(value) && value.every(isUser)
}

export async function getUsers(): Promise<User[]> {
  const request = await fetch('users.json')
  const data = await request.json()

  if (!isUsersArray(data)) {
    throw new Error('Invalid data: array with users expected')
  }

  return data
}

Using function getUsers() will then be guaranteed to have correct data. If you receive differing data, an error will be thrown. Now, the error message is not very useful: you would like to know what is wrong with the data, like, “Error: required field name is missing”. You can achieve that by writing a more extensive validation function that throws an informative error on every possible problem. This can get verbose, and it is useful to use a runtime validation library for that such as superstruct or joi. Here an example with superstruct:

import type { User } from './User'
import { validate, object, number, string, array, boolean } from 'superstruct'

const UsersStruct = array(
  object({
    id: string(),
    name: string(),
    age: number(),
    scores: array(number())
  })
)

export async function getUsers(): Promise<User[]> {
  const request = await fetch('users.json')
  const data = await request.json()

  const [error, users] = validate(data, UsersStruct)
  if (error) {
    throw error
  }

  return users
}

3. Type-cast with JSON Schema validation

A special case of a runtime validation is using a JSON Schema. Unlike hard-coded validation logic written in JavaScript, using a JSON Schema is more dynamic. A JSON Schema document can be shared between frontend and backend, and can even be fetched dynamically. You’re also using a broadly supported standard. It is important to realize that you have to keep your schema in sync with your TypeScript model (this holds for the validation function explained in the previous section too).

For example using the ajv library, we can use a JSON Schema document to validate our data structure:

import type { User } from './User'
import Ajv, { type JSONSchemaType } from 'ajv'
const ajv = new Ajv()

// Note: the JSON Schema can be stored separately
// instead of having it here hardcoded
const UsersSchema: JSONSchemaType<User[]> = {
  type: 'array',
  items: {
    type: 'object',
    properties: {
      id: { type: 'string' },
      name: { type: 'string' },
      age: { type: 'number' },
      scores: {
        type: 'array',
        items: {
          type: 'number'
        }
      }
    },
    required: ['id', 'name', 'age', 'scores']
  }
}

const validate = ajv.compile<User[]>(UsersSchema)

export async function getUsers(): Promise<User[]> {
  const request = await fetch('users.json')
  const data = await request.json()

  if (validate(data)) {
    return data
  } else {
    const error = validate.errors[0]
    throw new Error(`Invalid data: ${error.message} (path: ${error.instancePath})`)
  }
}

4. Use a TypeScript plugin to validate and cast data

By now, you may be thinking: writing out the validation rules requires quite some work. Can’t we automatically generate them based on the TypeScript model? This is indeed possible.

Since TypeScript does not exist at runtime, we have to use a TypeScript plugin that generates JavaScript validation code when compiling the TypeScript code. One library that can do this for you is typia. You can use this as follows to get runtime type validation:

import type { User } from './User'
import { assertEquals } from 'typia'

export async function getUsers(): Promise<User[]> {
  const request = await fetch('users.json')
  const data = await request.json()

  return assertEquals<User[]>(data)
}

To make this solution work, you have to do some configuration for TypeScript and the build tool that you use, like Vite or Webpack. This makes validation a no-brainer and can be a really smooth solution.

Dealing with advanced data types

Now, there is one caveat to the demonstrated solutions above: they only work for primitive data types supported by JSON: object, array, string, number, boolean, null. But it does not support Date or other custom classes. To solve this, we have to turn the plain JSON data into instantiated classes. This can be done using a JSON reviver as explained in the article “JSON date format: 3 ways to work with dates in JSON”, or by writing a conversion function. Which approach is handiest depends on your data model.

So, the steps that need to be taken are the following:

  1. Fetch data
  2. Parse the data
  3. Validate the data
  4. Convert the raw data into classes (like Date) where needed

Suppose we add a property updated with an ISO Date string to the user data:

[
  {
    "id": "1042",
    "name": "Joe",
    "age": 27,
    "scores": [31.4, 29.9, 35.7],
    "updated": "2022-12-20T16:24:00.000Z"
  },
  {
    "id": "1071",
    "name": "Sarah",
    "age": 29,
    "scores": [25.0, 27.1],
    "updated": "2022-11-06T08:15:00.000Z"
  }
]

We now have to define two TypeScript models: the “raw” JSON data model, and the final data model with instantiated classes, and a conversion function which turns property updated into a JavaScript Date.

import { assertEquals } from 'typia'

interface RawExtendedUser {
  id: string
  name: string
  age: number
  scores: number[]
  updated: string
}

interface ExtendedUser {
  id: string
  name: string
  age: number
  scores: number[]
  updated: Date
}

function toExtendedUser(rawUser: RawExtendedUser): ExtendedUser {
  return {
    ...rawUser,
    updated: new Date(rawUser.updated)
  }
}

export async function getUsers(): Promise<ExtendedUser[]> {
  const request = await fetch('users.json')
  const data = await request.json()
  const rawUsers = assertEquals<RawExtendedUser[]>(data)
  const users = rawUsers.map(toExtendedUser)

  return users
}

Conclusion on JSON to TypeScript

There are various ways to go about turning an unstructured JSON document into a TypeScript model. The simplest approach is to just cast the data to your model via data as User[]. This does not guarantee that the data actually contains a list with users though! It is recommended to validate the data before casting it into a TypeScript model. You can do that by writing a custom validation function or using a validation library. And lastly, you can automatically generate validation logic for your TypeScript classes by using a TypeScript plugin such as typia.

Which approach is the best fit depends on your situation:

  • Do you have a simple data model, or more advanced classes and data types like Date?
  • Do you want to share a data model with the backend somehow?
  • What kind of validation library fits best with your application and personal preference?
  • How much control do you need over the validation errors and class instantiation?

Going over the options will make clear what you need for your case. Happy coding!