← View all articles
8 min read

TypeScript: modelling API responses

  • TypeScript
  • Vue.js
  • API
  • Frontend

An API can return nested structures, optional fields and data that varies by state. Using a certificate management example, this article shows how to organise TypeScript interfaces and use them in Vue composables.

The Starting Point: An API With a Hierarchical Structure

Consider a certificate management API that returns structures of the following shape:

{
  "account": {
    "id": "ACC-001",
    "name": "EDF Production",
    "type": "PRODUCER"
  },
  "certificates": [
    {
      "id": "GO-2024-001",
      "volume": 1500.5,
      "unit": "MWh",
      "period": { "from": "2024-01-01", "to": "2024-01-31" },
      "status": "ACTIVE",
      "metadata": {
        "technology": "WIND",
        "country": "FR",
        "installation_id": "INS-042"
      }
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total": 312
  }
}

Modelling the Atomic Types

Start with the leaf-level types — literal unions rather than bare strings:

// types/api.ts

export type AccountType = "PRODUCER" | "TRADER" | "CONSUMER"
export type CertificateStatus = "ACTIVE" | "CANCELLED" | "TRANSFERRED"
export type EnergyTechnology = "WIND" | "SOLAR" | "HYDRO" | "BIOMASS"
export type CountryCode = "FR" | "DE" | "ES" | "IT" | "BE"

export interface DateRange {
  from: string // ISO 8601
  to: string
}

Literal unions rather than raw string types: TypeScript will flag 'WIND_OFFSHORE' immediately wherever EnergyTechnology is expected, rather than letting it slip through to a runtime error.

Hierarchical Interfaces

export interface Account {
  id: string
  name: string
  type: AccountType
}

export interface CertificateMetadata {
  technology: EnergyTechnology
  country: CountryCode
  installation_id: string
}

export interface Certificate {
  id: string
  volume: number
  unit: "MWh" | "kWh"
  period: DateRange
  status: CertificateStatus
  metadata: CertificateMetadata
}

export interface Pagination {
  page: number
  per_page: number
  total: number
}

export interface CertificatesResponse {
  account: Account
  certificates: Certificate[]
  pagination: Pagination
}

Utility Types for Each Use Case

The API always returns the complete structure, but the application rarely needs all of it at once. TypeScript’s utility types allow deriving purpose-built types from the canonical interfaces without duplicating definitions:

// For list display — metadata is not needed
export type CertificateSummary = Pick<
  Certificate,
  "id" | "volume" | "unit" | "status" | "period"
>

// For updates — only status is mutable
export type CertificateUpdate = Pick<Certificate, "id"> & {
  status: CertificateStatus
}

// For search filters — all fields are optional
export type CertificateFilters = Partial<Pick<Certificate, "status">> & {
  period?: Partial<DateRange>
  technology?: EnergyTechnology
  country?: CountryCode
}

// For forms — exclude API-generated fields
export type CertificateForm = Omit<Certificate, "id" | "status">

Generic Composables in Vue

A generic API composable eliminates the need to rewrite the same loading/error logic for every endpoint:

// composables/useApiQuery.ts
import { ref, Ref } from "vue"

interface ApiQueryState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<string | null>
  execute: () => Promise<void>
}

export function useApiQuery<T>(
  fetcher: () => Promise<T>,
  options?: { immediate?: boolean },
): ApiQueryState<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<string | null>(null)

  const execute = async () => {
    loading.value = true
    error.value = null
    try {
      data.value = await fetcher()
    } catch (e) {
      error.value = e instanceof Error ? e.message : "Unknown error"
    } finally {
      loading.value = false
    }
  }

  if (options?.immediate) execute()

  return { data, loading, error, execute }
}

Usage in a Vue component:

const {
  data: certificates,
  loading,
  error,
  execute,
} = useApiQuery<CertificatesResponse>(
  () => $fetch("/api/certificates", { params: filters.value }),
  { immediate: true },
)

TypeScript infers data as Ref<CertificatesResponse | null> automatically — no manual casting, no as any.

Typing Vue Props Against API Interfaces

A common mistake is defining Vue props with local types that duplicate the API interfaces. The correct approach uses the canonical types directly:

// components/CertificateCard.vue
<script setup lang="ts">
import type { CertificateSummary, CertificateStatus } from '@/types/api'

const props = defineProps<{
  certificate: CertificateSummary
  selected?: boolean
}>()

const emit = defineEmits<{
  select: [id: string]
  statusChange: [id: string, status: CertificateStatus]
}>()
</script>

defineProps<T>() and defineEmits<T>() with generic types: no PropType<T> import required, and TypeScript validates props at compile time and within the template.

Type Guards for API Responses

Real-world APIs do not always honour their contracts. A type guard enables runtime validation without sacrificing static typing:

function isCertificate(value: unknown): value is Certificate {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "volume" in value &&
    "status" in value &&
    ["ACTIVE", "CANCELLED", "TRANSFERRED"].includes(
      (value as Certificate).status,
    )
  )
}

// Usage
const raw = await $fetch("/api/certificates/GO-2024-001")
if (!isCertificate(raw)) {
  throw new Error("Invalid API response")
}
// TypeScript now knows raw is of type Certificate
console.log(raw.volume)

What This Changes in Practice

Investing in rigorous TypeScript modelling from the outset delivers:

  • Reliable autocomplete across all API objects in the IDE
  • Errors caught at compile time rather than discovered in production
  • Safe refactoring — renaming a field in an interface propagates the error everywhere it is used
  • Implicit documentation — the types are the source of truth for the shape of the data

The upfront cost is real, particularly on an existing project with JavaScript to migrate. However, on a new Vue.js project consuming an external API, starting with strict TypeScript from the first commit is consistently the most cost-effective decision over the medium term.

Next articleThe BFF pattern with FastAPI and Vue.js →
Contact

Building something new or improving an existing application?

Let’s talk about your needs, constraints and next steps.

Let’s work together
© 2026 Hamed Bouare — All rights reservedAvailable for new projectsLinkedIn