Vue3 with strongly-typed Axios requests

Viewed 2014

I am trying to figure out a few "type safety" basics with Vue3, TypeScript and Axios.

It's pretty simple stuff, and I feel as though I am missing something obvious!

I have created a Book interface:

Book.ts

interface Book {
  id: string;
  userId: string;
  title: string;
  content: string;
}

export default Book;

I have also created a simple service to fetch some JSON, e.g.

  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },

(Note: The JSON has body rather than content as defined on the interface)

DataService.ts

import axios, { AxiosResponse } from "axios";
import Book from "@/interfaces/Book";

class DataService {
  async FetchBooks(): Promise<Book[]> {
    let response: Book[] = [
      { id: "1", userId: "someone", title: "A title", content: "Some content" }
    ];
    try {
      const val = await axios.get<Book[]>(
        `https://jsonplaceholder.typicode.com/posts`
      );

      if (val.data && val.status === 200) {
        response = val.data;
      }
    } catch (error) {
      console.log(error);
    }

    return response;
  }
}

export default new DataService();

My first question, is why does my response value from the service still contain "body" when it does not exist on the Book interface? I was hoping it would get dropped somewhere along the line as I am passing the type Book to the axios get request.

My second question is, why am I allowed to reference {{ book.body }} below, when again I am handling the response as a Book and body is not defined on the interface?

Book.vue

<template>
  <div v-for="book in books" :key="book.id">
    <div class="mb-2">
      {{ book.body }}
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import DataService from "@/services/DataService";

// eslint-disable-next-line no-unused-vars
import Book from "@/interfaces/Book";

export default defineComponent({
  async setup() {
    let books: Book[] = [];

    await DataService.FetchBooks().then(data => (books = data));

    return { books };
  }
});
</script>
1 Answers

Why does my response value from the service still contain "body" when it does not exist on the Book interface?

Because in TypeScript, interfaces are simply a way of defining "contracts" for an entity.

That said, an object can have more properties than what's defined on the contract, but the compiler only checks that at least the ones required are present and match the types required. There are some cases where TypeScript isn't as lenient. Learn more.

I was hoping it would get dropped somewhere along the line as I am passing the type Book to the axios get request.

So it won't, unless you redefine the individual response items with it (the body property) omitted or renamed to content—then and only then—will the contract function at its best. For instance:

const val = await axios
  .get<Array<Book & { body: string }>>(
    `https://jsonplaceholder.typicode.com/posts`
  )
  .then(res => res.data.map(
    ({ body, ...props }) => Object.assign({ content: body }, props)
  ));

To avoid cluttering the type assertion, I'd recommend adding an additional DTO for the original Book object—one that includes an intersection type for the "raw" body property. For example:

interface IBook {
  id: string;
  userId: string;
  title: string;
  content: string;
}

interface IBookRaw extends Omit<IBook, 'content'> {
  body: string;
}

With that one added, let's improve the previous data mapping with the help of a class that implements this interface, for example:

class Book implements IBook {
  id: string;
  userId: string;
  title: string;
  content: string;

  constructor({ id, userId, title, body }: IBookRaw) {
    this.id = id;
    this.userId = userId;
    this.title = title;
    this.content = body;
  }
}

const val = await axios
  .get<IBookRaw[]>(
    `https://jsonplaceholder.typicode.com/posts`
  )
  .then(res =>
    res.data.map(item => new Book(item))
  );

Why am I allowed to reference {{ book.body }} below, when again I am handling the response as a Book and body is not defined on the interface?

Talking about template interpolation, if you use Vetur, you'll need to enable vetur.experimental.templateInterpolationService to get the benefit of the linting hints. Once enabled, you shouldn't be able compile without first fixing the "missing property" error on the template (which is again to either rename the prop or omit it).

Otherwise (without Vetur's template interpolation service), there will be no type checking and of course it will end up evaluating to undefined.

Related