TypeScript in Practice: Hierarchical Interfaces on a Real API

Most TypeScript tutorials demonstrate trivial examples: interface User { name: string; age: number }. Production code is considerably more involved. External APIs return nested structures, conditionally optional fields, and type unions that vary by state. Here is how to model all of that cleanly, starting from a real-world case.

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.