Skip to content

Instantly share code, notes, and snippets.

@jericbas
Created February 18, 2026 11:00
Show Gist options
  • Select an option

  • Save jericbas/3368b5376556829f17bf297ee5da0a47 to your computer and use it in GitHub Desktop.

Select an option

Save jericbas/3368b5376556829f17bf297ee5da0a47 to your computer and use it in GitHub Desktop.
Modern FreecurrencyAPI TypeScript rewrite with async/await, AbortController, and full type safety
/**
* @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