DEV Community

allratestoday
allratestoday

Posted on Originally published at allratestoday.com

Currency API in TypeScript: A Typed Exchange Rate Client

Most currency API JavaScript tutorials stop at fetch(url).then(r => r.json()) and an any. That works until a field name changes, a request hangs, or a 429 arrives and your code reads undefined as a rate. This tutorial builds a small typed currency API client in TypeScript: response types, a fetch wrapper with a timeout, error classes for 401 and 429, runtime validation and a cache. It runs unchanged on Node 18+, Deno, Bun and Cloudflare Workers, because it only uses fetch, AbortSignal and Map.

The examples use the AllRatesToday API. The patterns apply to any exchange rate API.

The response you are typing

GET /api/v1/rates?source=USD&target=EUR returns a JSON array, even for one pair:

[{"rate":0.9215,"source":"USD","target":"EUR","time":"2026-04-03T12:00:00Z"}]
Enter fullscreen mode Exit fullscreen mode

Several targets in one call, such as target=EUR,GBP,JPY,CAD, return one array with one element per pair and cost one request. The most common bug with this API is reading res.rate instead of res[0].rate. Types catch that at compile time.

Step 1: types for the currency API response

export type CurrencyCode = string; // ISO 4217, e.g. "USD"

export interface Rate {
  rate: number;
  source: CurrencyCode;
  target: CurrencyCode;
  time: string; // ISO 8601 UTC timestamp
}

export type RatesResponse = Rate[];

export interface ApiErrorBody {
  error: string;
  hint?: string;
}
Enter fullscreen mode Exit fullscreen mode

Keep time as a string in the wire type. Convert to Date at the edge where you need it.

Step 2: error types for 401, 429 and the rest

A typed error hierarchy lets callers decide what to do without parsing messages.

export class RateApiError extends Error {
  constructor(message: string, readonly status: number) {
    super(message);
    this.name = "RateApiError";
  }
}

export class AuthError extends RateApiError {
  constructor(message: string) {
    super(message, 401);
    this.name = "AuthError";
  }
}

export class QuotaError extends RateApiError {
  constructor(
    message: string,
    readonly limit: number | null,
    readonly used: number | null,
    readonly resets: string | null
  ) {
    super(message, 429);
    this.name = "QuotaError";
  }
}

export class TimeoutError extends RateApiError {
  constructor(ms: number) {
    super(`Request timed out after ${ms} ms`, 0);
    this.name = "TimeoutError";
  }
}
Enter fullscreen mode Exit fullscreen mode

A 401 means the key is missing or wrong. Retrying will not help. A 429 means you are over your plan's limit. The API sends X-Monthly-Limit, X-Monthly-Used and X-Monthly-Resets headers on 429 responses only, so read them there. Rejected 401, 403 and 429 requests are not charged.

Step 3: runtime validation

TypeScript types disappear at runtime. A hand-written guard is enough for a response this small:

function isRate(x: unknown): x is Rate {
  if (typeof x !== "object" || x === null) return false;
  const r = x as Record<string, unknown>;
  return (
    typeof r.rate === "number" &&
    Number.isFinite(r.rate) &&
    r.rate > 0 &&
    typeof r.source === "string" &&
    typeof r.target === "string" &&
    typeof r.time === "string" &&
    !Number.isNaN(Date.parse(r.time))
  );
}

function parseRates(data: unknown): RatesResponse {
  if (!Array.isArray(data) || !data.every(isRate)) {
    throw new RateApiError("Unexpected response shape", 200);
  }
  return data;
}
Enter fullscreen mode Exit fullscreen mode

If you already use zod, the same schema is shorter:

import { z } from "zod";

const RateSchema = z.object({
  rate: z.number().positive(),
  source: z.string().length(3),
  target: z.string().length(3),
  time: z.string().datetime(),
});
const RatesSchema = z.array(RateSchema);

// const rates = RatesSchema.parse(await res.json());
Enter fullscreen mode Exit fullscreen mode

Either way, the rest of your code only ever sees a valid Rate[].

Step 4: fetch wrapper with a timeout

fetch has no default timeout. A hung connection can hold a request open for a long time. AbortSignal.timeout() fixes that and is available in Node 18+, Deno, Bun and Workers.

export interface ClientOptions {
  apiKey: string;
  baseUrl?: string;
  timeoutMs?: number;
}

export class RatesClient {
  private baseUrl: string;
  private timeoutMs: number;

  constructor(private opts: ClientOptions) {
    this.baseUrl = opts.baseUrl ?? "https://allratestoday.com";
    this.timeoutMs = opts.timeoutMs ?? 5000;
  }

  private async request(path: string): Promise<unknown> {
    let res: Response;
    try {
      res = await fetch(this.baseUrl + path, {
        headers: { Authorization: `Bearer ${this.opts.apiKey}` },
        signal: AbortSignal.timeout(this.timeoutMs),
      });
    } catch (err) {
      if (err instanceof DOMException && err.name === "TimeoutError") {
        throw new TimeoutError(this.timeoutMs);
      }
      throw err; // network error
    }

    if (res.status === 401) {
      const body = (await res.json().catch(() => ({}))) as Partial<ApiErrorBody>;
      throw new AuthError(body.error ?? "Unauthorized");
    }
    if (res.status === 429) {
      const num = (h: string) => {
        const v = res.headers.get(h);
        return v === null ? null : Number(v);
      };
      throw new QuotaError(
        "Monthly request limit reached",
        num("X-Monthly-Limit"),
        num("X-Monthly-Used"),
        res.headers.get("X-Monthly-Resets")
      );
    }
    if (!res.ok) {
      throw new RateApiError(`HTTP ${res.status}`, res.status);
    }
    return res.json();
  }

  async rates(source: CurrencyCode, targets: CurrencyCode[]): Promise<RatesResponse> {
    const qs = new URLSearchParams({ source, target: targets.join(",") });
    return parseRates(await this.request(`/api/v1/rates?${qs}`));
  }

  async rate(source: CurrencyCode, target: CurrencyCode): Promise<Rate> {
    const [first] = await this.rates(source, [target]);
    if (!first) throw new RateApiError("Empty response", 200);
    return first;
  }
}
Enter fullscreen mode Exit fullscreen mode

On older runtimes without AbortSignal.timeout, create an AbortController, call controller.abort() from a setTimeout, and clear the timer in a finally block.

Step 5: a small cache

Rates refresh every 60 seconds, so caching for a minute loses nothing and cuts requests sharply. Cache by base currency and fetch all the targets you need in one call.

export class CachedRatesClient {
  private cache = new Map<string, { at: number; data: Map<CurrencyCode, Rate> }>();

  constructor(
    private client: RatesClient,
    private targets: CurrencyCode[],
    private ttlMs = 60_000
  ) {}

  async get(source: CurrencyCode, target: CurrencyCode): Promise<Rate> {
    const hit = this.cache.get(source);
    if (hit && Date.now() - hit.at < this.ttlMs) {
      const r = hit.data.get(target);
      if (r) return r;
    }
    const list = await this.client.rates(source, this.targets);
    const data = new Map(list.map((r) => [r.target, r] as const));
    this.cache.set(source, { at: Date.now(), data });
    const r = data.get(target);
    if (!r) throw new RateApiError(`No rate for ${source}/${target}`, 200);
    return r;
  }
}
Enter fullscreen mode Exit fullscreen mode

In a Cloudflare Worker, an in-memory Map lives only as long as the isolate. For a cache shared across requests, use the Cache API or KV with the same TTL. More options are in how to cache exchange rates.

Using the client

const client = new RatesClient({ apiKey: process.env.ALLRATES_API_KEY! });
const cached = new CachedRatesClient(client, ["EUR", "GBP", "JPY", "CAD"]);

try {
  const eur = await cached.get("USD", "EUR");
  console.log(`1 USD = ${eur.rate} EUR (as of ${eur.time})`);
} catch (err) {
  if (err instanceof AuthError) console.error("Check the API key");
  else if (err instanceof QuotaError) console.error(`Quota reached, resets ${err.resets}`);
  else if (err instanceof TimeoutError) console.error("Rate service slow, use cached value");
  else throw err;
}
Enter fullscreen mode Exit fullscreen mode

On Deno, read the key with Deno.env.get("ALLRATES_API_KEY"). On Bun, process.env works as in Node. In a Worker, read it from the env binding passed to fetch. The client code itself does not change.

Runtime notes

Runtime fetch AbortSignal.timeout Key from
Node 18+ Built in Yes (17.3+) process.env
Deno Built in Yes Deno.env.get()
Bun Built in Yes process.env or Bun.env
Cloudflare Workers Built in Yes env binding

Keep the key on the server. The keyed API does not send CORS headers, so a browser app should call your backend, and the backend calls the API.

Or use the SDK

If you would rather not maintain this yourself, npm install @allratestoday/sdk ships with full types, has no dependencies and runs on Node.js 18+, Bun, Deno and edge runtimes. See the SDK docs for its methods.

For retries, fallback providers and staleness checks on top of this client, read exchange rate API fallback and error handling. To compare providers before you pick one, see the free currency API comparison.

FAQ

Does the AllRatesToday API return an object or an array?

GET /api/v1/rates returns a JSON array, even for a single pair. Type it as an array and read rates[0].rate, not rates.rate.

How do I add a timeout to fetch in TypeScript?

Use AbortSignal.timeout(ms), or create an AbortController and call abort() from a setTimeout. Both work in Node 18+, Deno, Bun and Cloudflare Workers.

Do I need zod to validate API responses?

No. A hand-written type guard of 15 lines covers a small response like this one. Zod is worth it if you already use it, or if you validate many different responses.

Can I call the API from the browser with TypeScript?

Keep the key on a server. The keyed API does not send CORS headers, so browser code should call your own backend, which then calls the API with the Bearer header.

Is there an official TypeScript SDK?

Yes. npm install @allratestoday/sdk ships with full types and runs on Node.js 18+, Bun, Deno and edge runtimes. The client in this article shows what a typed wrapper does if you prefer to write your own.

Related

Originally published at allratestoday.com.

Top comments (0)