Created
February 18, 2026 11:00
-
-
Save jericbas/3368b5376556829f17bf297ee5da0a47 to your computer and use it in GitHub Desktop.
Modern FreecurrencyAPI TypeScript rewrite with async/await, AbortController, and full type safety
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @module FreecurrencyAPI | |
| * @description A modern, TypeScript-native client for the Freecurrency API v1. | |
| * | |
| * Features: | |
| * - ✅ Full TypeScript support with complete type definitions | |
| * - ✅ Modern async/await syntax (no promise chains) | |
| * - ✅ AbortController for request cancellation and timeouts | |
| * - ✅ Proper error handling with custom error classes | |
| * - ✅ Zero runtime dependencies | |
| * | |
| * @example | |
| * ```typescript | |
| * import FreecurrencyAPI, { CurrenciesResponse, LatestResponse } from './freecurrencyapi'; | |
| * | |
| * const client = new FreecurrencyAPI('your-api-key'); | |
| * | |
| * // Get current exchange rates | |
| * const rates = await client.latest({ base_currency: 'USD', currencies: ['EUR', 'GBP'] }); | |
| * | |
| * // Check API status | |
| * const status = await client.status(); | |
| * ``` | |
| */ | |
| // ============================================================================ | |
| // TYPES | |
| // ============================================================================ | |
| /** Available API endpoints */ | |
| type Endpoint = 'status' | 'currencies' | 'latest' | 'historical'; | |
| /** Base response structure from all API calls */ | |
| interface BaseResponse { | |
| data: unknown; | |
| } | |
| /** API status response */ | |
| export interface Status { | |
| account_id: number; | |
| quotas: Quotas; | |
| } | |
| interface Quotas { | |
| month: QuotaDetail; | |
| grace: QuotaDetail; | |
| } | |
| interface QuotaDetail { | |
| total: number; | |
| used: number; | |
| remaining: number; | |
| } | |
| /** Currency metadata */ | |
| export interface Currency { | |
| symbol: string; | |
| name: string; | |
| symbol_native: string; | |
| decimal_digits: number; | |
| rounding: number; | |
| code: string; | |
| name_plural: string; | |
| } | |
| /** Response from /currencies endpoint */ | |
| export interface CurrenciesResponse extends BaseResponse { | |
| data: Record<string, Currency>; | |
| } | |
| /** Response from /latest and /historical endpoints */ | |
| export interface LatestResponse extends BaseResponse { | |
| data: Record<string, number>; | |
| } | |
| /** | |
| * `{"data":{"ERL":134.2, ...}}` | |
| */ | |
| export interface HistoricalResponse extends BaseResponse { | |
| data: Record<string, number>; | |
| } | |
| /** Valid ISO 4217 currency codes */ | |
| type CurrencyCode = string; | |
| /** Parameters for /currencies endpoint */ | |
| export interface CurrenciesParams { | |
| /** ISO codes to filter by, e.g. ['USD', 'EUR'] */ | |
| currencies?: CurrencyCode[]; | |
| } | |
| /** Parameters for /latest endpoint */ | |
| export interface LatestParams { | |
| /** Base currency, defaults to 'USD' */ | |
| base_currency?: CurrencyCode; | |
| /** Quote currencies to return, all if omitted */ | |
| currencies?: CurrencyCode[]; | |
| } | |
| /** Parameters for /historical endpoint */ | |
| export interface HistoricalParams { | |
| /** Date in YYYY-MM-DD format */ | |
| date: string; | |
| /** Base currency */ | |
| base_currency?: CurrencyCode; | |
| /** Quote currencies to return */ | |
| currencies?: CurrencyCode[]; | |
| } | |
| /** HTTP methods supported */ | |
| type HttpMethod = 'GET' | 'POST'; | |
| /** Request configuration options */ | |
| export interface RequestConfig { | |
| /** Request timeout in milliseconds */ | |
| timeout?: number; | |
| /** Custom signal for request cancellation */ | |
| signal?: AbortSignal; | |
| } | |
| // ============================================================================ | |
| // ERRORS | |
| // ============================================================================ | |
| /** | |
| * Base error class for API-related errors | |
| * @extends Error | |
| */ | |
| export class FreecurrencyAPIError extends Error { | |
| constructor( | |
| message: string, | |
| public readonly status?: number, | |
| public readonly response?: Response | |
| ) { | |
| super(message); | |
| this.name = 'FreecurrencyAPIError'; | |
| Object.setPrototypeOf(this, FreecurrencyAPIError.prototype); | |
| } | |
| } | |
| /** | |
| * Error thrown when API quota is exceeded | |
| * @extends FreecurrencyAPIError | |
| */ | |
| export class QuotaExceededError extends FreecurrencyAPIError { | |
| constructor(message = 'API quota exceeded') { | |
| super(message, 429); | |
| this.name = 'QuotaExceededError'; | |
| } | |
| } | |
| /** | |
| * Error thrown when request times out | |
| * @extends FreecurrencyAPIError | |
| */ | |
| export class TimeoutError extends FreecurrencyAPIError { | |
| constructor(timeoutMs: number) { | |
| super(`Request timed out after ${timeoutMs}ms`); | |
| this.name = 'TimeoutError'; | |
| } | |
| } | |
| // ============================================================================ | |
| // MAIN CLASS | |
| // ============================================================================ | |
| /** | |
| * FreecurrencyAPI client for interacting with freecurrencyapi.com | |
| * | |
| * @example | |
| * ```typescript | |
| * const api = new FreecurrencyAPI(process.env.FCAPI_KEY); | |
| * | |
| * // With custom timeout | |
| * const rates = await api.latest( | |
| * { base_currency: 'EUR' }, | |
| * { timeout: 5000 } | |
| * ); | |
| * ``` | |
| */ | |
| export default class FreecurrencyAPI { | |
| private readonly baseUrl = 'https://api.freecurrencyapi.com/v1/'; | |
| private readonly headers: Record<string, string>; | |
| /** | |
| * Creates a new FreecurrencyAPI client instance | |
| * @param apiKey - Your API key from freecurrencyapi.com | |
| */ | |
| constructor(apiKey: string = '') { | |
| this.headers = { apikey: apiKey }; | |
| } | |
| // ========================================================================== | |
| // PRIVATE METHODS | |
| // ========================================================================== | |
| /** | |
| * Builds query string from params object | |
| */ | |
| private buildQueryString(params: Record<string, unknown>): string { | |
| const searchParams = new URLSearchParams(); | |
| for (const [key, value] of Object.entries(params)) { | |
| if (value === undefined || value === null) continue; | |
| // Handle arrays (e.g., currencies=['USD','EUR']) | |
| if (Array.isArray(value)) { | |
| searchParams.set(key, value.join(',')); | |
| } else { | |
| searchParams.set(key, String(value)); | |
| } | |
| } | |
| const query = searchParams.toString(); | |
| return query ? `?${query}` : ''; | |
| } | |
| /** | |
| * Creates an AbortSignal with timeout | |
| */ | |
| private createTimeoutSignal(timeoutMs: number, externalSignal?: AbortSignal): AbortSignal { | |
| const controller = new AbortController(); | |
| // Handle external signal cancellation | |
| if (externalSignal) { | |
| externalSignal.addEventListener('abort', () => controller.abort(), { once: true }); | |
| } | |
| // Setup timeout | |
| const timeoutId = setTimeout(() => { | |
| controller.abort(new TimeoutError(timeoutMs)); | |
| }, timeoutMs); | |
| // Cleanup timeout on abort | |
| controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true }); | |
| return controller.signal; | |
| } | |
| /** | |
| * Core HTTP request handler | |
| */ | |
| private async request<T extends BaseResponse>( | |
| endpoint: Endpoint, | |
| params: Record<string, unknown> = {}, | |
| config: RequestConfig = {} | |
| ): Promise<T> { | |
| const url = `${this.baseUrl}${endpoint}${this.buildQueryString(params)}`; | |
| // Setup abort signal (with optional timeout) | |
| const signal = config.timeout | |
| ? this.createTimeoutSignal(config.timeout, config.signal) | |
| : config.signal; | |
| try { | |
| const response = await fetch(url, { | |
| headers: this.headers, | |
| signal, | |
| }); | |
| // Handle HTTP errors | |
| if (!response.ok) { | |
| if (response.status === 429) { | |
| throw new QuotaExceededError(); | |
| } | |
| throw new FreecurrencyAPIError( | |
| `HTTP ${response.status}: ${response.statusText}`, | |
| response.status, | |
| response | |
| ); | |
| } | |
| const data = await response.json() as T; | |
| return data; | |
| } catch (error) { | |
| // Re-throw if it's already our error type | |
| if (error instanceof FreecurrencyAPIError) { | |
| throw error; | |
| } | |
| // Handle fetch errors (network, CORS, etc) | |
| if (error instanceof TypeError) { | |
| throw new FreecurrencyAPIError(`Network error: ${error.message}`); | |
| } | |
| // Handle abort errors |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment