---
title: "Currency API in TypeScript: A Typed Exchange Rate Client"
description: "Build a typed currency API client in TypeScript: response types, fetch timeouts, 401/429 errors, runtime validation and caching for Node, Deno, Bun and Workers."
canonical: https://allratestoday.com/blog/currency-api-typescript/
source: AllRatesToday (https://allratestoday.com/) — Markdown mirror of the page above; index of all pages at https://allratestoday.com/llms.txt
generated: 2026-11-11T14:57:32.345Z
---

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"}]
```

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;
}
```

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";
  }
}
```

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;
}
```

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());
```

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;
  }
}
```

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;
  }
}
```

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](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/).

## 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;
}
```

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](https://allratestoday.com/docs/sdks/) for its methods.

For retries, fallback providers and staleness checks on top of this client, read [exchange rate API fallback and error handling](https://allratestoday.com/blog/exchange-rate-api-fallback-error-handling/). To compare providers before you pick one, see the [free currency API comparison](https://allratestoday.com/blog/best-free-currency-exchange-api-2026/).

## 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.

## Get a free API key

Mid-market rates for 160+ currencies, refreshed every 60 seconds on every plan. Test the client above with a free API key.

 [Get a free API key](https://allratestoday.com/register)

## Related Articles

- [Free currency converter API in JavaScript](https://allratestoday.com/blog/free-currency-converter-api-javascript/)
- [Best exchange rate API for Node.js](https://allratestoday.com/blog/best-exchange-rate-api-for-nodejs-2026/)
- [How to cache exchange rates and avoid rate limits](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/)
- [Exchange rate API fallback and error handling](https://allratestoday.com/blog/exchange-rate-api-fallback-error-handling/)
- [API documentation](https://allratestoday.com/docs/)
