Working with external APIs in JavaScript often means accepting any at every boundary — trusting that the shape of a response matches your assumptions. TypeScript eliminates that trust requirement. When you define precise interfaces for API responses, the compiler becomes your safety net: wrong property names, missing fields, and incorrect types all surface at build time rather than in production.
This guide walks through building a fully type-safe Threads API client in TypeScript using thredly — a Threads API proxy designed with developer experience in mind. You will define response interfaces, implement a reusable client class, handle errors with discriminated unions, and integrate the client into both Next.js and Express applications.
Prerequisites
Before starting, ensure you have the following:
- Node.js 18+ — required for the native
fetchAPI - TypeScript 5+ — for satisfies operator and const type parameters
- A thredly API key — obtain one from RapidAPI
Initialize a TypeScript project if you do not already have one:
npm init -y
npm install typescript tsx @types/node --save-dev
npx tsc --init
Set "strict": true in your tsconfig.json. Strict mode catches the class of errors that make API integrations brittle.
Define Response Types
Start with the data shapes. Every endpoint returns a predictable structure, and capturing that structure in TypeScript interfaces locks down the contract between the API and your application code.
// types/threads.ts
export interface ThreadsUser {
username: string;
full_name: string;
biography: string;
follower_count: number;
following_count: number;
is_verified: boolean;
profile_pic_url: string;
}
export interface ThreadsPost {
id: string;
text: string;
like_count: number;
reply_count: number;
created_at: string;
media_url?: string; // optional — only present on media posts
}
export interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
The generic ApiResponse<T> wrapper mirrors the actual envelope returned by the thredly API. Using a generic here means every endpoint shares the same outer shape while the data field carries the endpoint-specific payload — ThreadsUser, ThreadsPost[], or any other type you define.
The ? on media_url is deliberate. Not all posts carry media, and marking it optional forces every consumer of ThreadsPost to handle the absent case explicitly rather than assuming the property exists.
Create an API Client
A reusable client class centralises the configuration (base URL, headers) and exposes typed methods. Callers never construct fetch calls directly — they call methods and receive typed responses.
// lib/threads-client.ts
import type {
ThreadsUser,
ThreadsPost,
ApiResponse,
} from "../types/threads.js";
export class ThreadsApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly endpoint: string,
message: string
) {
super(message);
this.name = "ThreadsApiError";
}
}
export class ThreadsClient {
private readonly baseUrl = "https://threads-api-pro.p.rapidapi.com";
private readonly headers: Record<string, string>;
constructor(apiKey: string) {
this.headers = {
"X-RapidAPI-Key": apiKey,
"X-RapidAPI-Host": "threads-api-pro.p.rapidapi.com",
"Content-Type": "application/json",
};
}
private async request<T>(path: string): Promise<T> {
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, { headers: this.headers });
if (!response.ok) {
throw new ThreadsApiError(
response.status,
path,
`Request failed: ${response.status} ${response.statusText}`
);
}
const body: ApiResponse<T> = await response.json();
if (!body.success) {
throw new ThreadsApiError(
response.status,
path,
body.error ?? "Unknown API error"
);
}
return body.data;
}
async getUser(username: string): Promise<ThreadsUser> {
return this.request<ThreadsUser>(`/api/user/${username}`);
}
async getUserPosts(username: string): Promise<ThreadsPost[]> {
return this.request<ThreadsPost[]>(`/api/user/${username}/posts`);
}
async searchUsers(query: string): Promise<ThreadsUser[]> {
const encoded = encodeURIComponent(query);
return this.request<ThreadsUser[]>(`/api/search/users?q=${encoded}`);
}
}
The private request<T> method is the single place where HTTP concerns live. Each public method provides the endpoint path and declares the expected return type — TypeScript infers the generic parameter from the call site, so this.request<ThreadsUser[]>(...) returns Promise<ThreadsUser[]> without any casting.
The custom ThreadsApiError class preserves the HTTP status code and endpoint path, which is essential for structured logging and conditional error handling downstream.
Fetch a User Profile
With the client in place, fetching a profile is a single awaited call. TypeScript infers the complete shape of the returned object.
// examples/fetch-profile.ts
import { ThreadsClient } from "../lib/threads-client.js";
const client = new ThreadsClient(process.env.THREADS_API_KEY!);
async function printProfile(username: string): Promise<void> {
const user = await client.getUser(username);
// TypeScript knows every property — no type assertions needed
console.log(`Handle: @${user.username}`);
console.log(`Name: ${user.full_name}`);
console.log(`Bio: ${user.biography}`);
console.log(`Followers: ${user.follower_count.toLocaleString()}`);
console.log(`Following: ${user.following_count.toLocaleString()}`);
console.log(`Verified: ${user.is_verified ? "yes" : "no"}`);
}
printProfile("threads").catch(console.error);
Because ThreadsUser is fully typed, accessing a non-existent property such as user.followerCount (camel-case, wrong) is a compile-time error. The IDE also surfaces autocomplete for every field the moment you type user..
Fetch and Filter Posts
The posts endpoint returns an array, which opens up the full TypeScript array method ecosystem with complete type inference throughout.
// examples/fetch-posts.ts
import { ThreadsClient } from "../lib/threads-client.js";
import type { ThreadsPost } from "../types/threads.js";
const client = new ThreadsClient(process.env.THREADS_API_KEY!);
async function getHighEngagementPosts(
username: string,
minLikes: number
): Promise<ThreadsPost[]> {
const posts = await client.getUserPosts(username);
return posts
.filter((post) => post.like_count >= minLikes)
.sort((a, b) => {
// Sort descending by date — TypeScript narrows string to Date-parseable
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
async function main() {
const trending = await getHighEngagementPosts("threads", 1000);
for (const post of trending) {
const date = new Date(post.created_at).toLocaleDateString();
console.log(`[${date}] ${post.like_count} likes — ${post.text.slice(0, 80)}`);
if (post.media_url) {
// TypeScript narrows media_url to string here — the undefined case is excluded
console.log(` Media: ${post.media_url}`);
}
}
}
main().catch(console.error);
The narrowing inside the if (post.media_url) block is a concrete benefit of marking the property optional. TypeScript knows that inside the block the value is a string, not string | undefined, so you can call string methods on it without a non-null assertion.
Build an Engagement Analyzer
A practical use case for Threads API TypeScript work is calculating engagement rate — the ratio of total interactions to followers. Combining both endpoints illustrates how typed data flows through multi-step operations.
// lib/engagement-analyzer.ts
import type { ThreadsUser, ThreadsPost } from "../types/threads.js";
export interface EngagementMetrics {
username: string;
follower_count: number;
post_count: number;
avg_likes: number;
avg_replies: number;
engagement_rate: number; // percentage
top_post: ThreadsPost | null;
}
export function analyzeEngagement(
user: ThreadsUser,
posts: ThreadsPost[]
): EngagementMetrics {
if (posts.length === 0) {
return {
username: user.username,
follower_count: user.follower_count,
post_count: 0,
avg_likes: 0,
avg_replies: 0,
engagement_rate: 0,
top_post: null,
};
}
const totalLikes = posts.reduce((sum, p) => sum + p.like_count, 0);
const totalReplies = posts.reduce((sum, p) => sum + p.reply_count, 0);
const totalInteractions = totalLikes + totalReplies;
const topPost = posts.reduce((best, post) =>
post.like_count > best.like_count ? post : best
);
return {
username: user.username,
follower_count: user.follower_count,
post_count: posts.length,
avg_likes: Math.round(totalLikes / posts.length),
avg_replies: Math.round(totalReplies / posts.length),
engagement_rate:
user.follower_count > 0
? parseFloat(((totalInteractions / user.follower_count) * 100).toFixed(2))
: 0,
top_post: topPost,
};
}
// examples/run-analyzer.ts
import { ThreadsClient } from "../lib/threads-client.js";
import { analyzeEngagement } from "../lib/engagement-analyzer.js";
const client = new ThreadsClient(process.env.THREADS_API_KEY!);
async function analyze(username: string) {
const [user, posts] = await Promise.all([
client.getUser(username),
client.getUserPosts(username),
]);
const metrics = analyzeEngagement(user, posts);
console.log(`Engagement report for @${metrics.username}`);
console.log(`Followers: ${metrics.follower_count.toLocaleString()}`);
console.log(`Posts analyzed: ${metrics.post_count}`);
console.log(`Avg likes: ${metrics.avg_likes}`);
console.log(`Avg replies: ${metrics.avg_replies}`);
console.log(`Engagement rate: ${metrics.engagement_rate}%`);
}
analyze("threads").catch(console.error);
Promise.all runs both requests concurrently. TypeScript correctly infers the destructured tuple type: [ThreadsUser, ThreadsPost[]]. No manual type annotations are needed on the destructuring.
Error Handling with Discriminated Unions
The client above throws on errors, which is fine for scripts. For application code, a Result<T, E> pattern avoids uncaught exceptions and makes error handling explicit at the call site.
// types/result.ts
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// lib/safe-client.ts
import { ThreadsClient, ThreadsApiError } from "./threads-client.js";
import type { ThreadsUser, ThreadsPost } from "../types/threads.js";
import type { Result } from "../types/result.js";
export class SafeThreadsClient {
private client: ThreadsClient;
constructor(apiKey: string) {
this.client = new ThreadsClient(apiKey);
}
async getUser(username: string): Promise<Result<ThreadsUser, ThreadsApiError>> {
try {
const data = await this.client.getUser(username);
return { success: true, data };
} catch (error) {
if (error instanceof ThreadsApiError) {
return { success: false, error };
}
return {
success: false,
error: new ThreadsApiError(500, `/api/user/${username}`, String(error)),
};
}
}
async getUserPosts(
username: string
): Promise<Result<ThreadsPost[], ThreadsApiError>> {
try {
const data = await this.client.getUserPosts(username);
return { success: true, data };
} catch (error) {
if (error instanceof ThreadsApiError) {
return { success: false, error };
}
return {
success: false,
error: new ThreadsApiError(500, `/api/user/${username}/posts`, String(error)),
};
}
}
}
At the call site, TypeScript requires you to check result.success before accessing result.data. The discriminated union narrows the type:
const result = await safeClient.getUser("threads");
if (!result.success) {
// TypeScript knows result.error is ThreadsApiError here
console.error(`HTTP ${result.error.statusCode}: ${result.error.message}`);
process.exit(1);
}
// TypeScript knows result.data is ThreadsUser here
console.log(result.data.full_name);
Attempting to access result.data before the check is a compile-time error. The pattern removes an entire category of runtime undefined errors from API code.
Using with Next.js and Express
Next.js App Router
// app/profile/[username]/page.tsx
import { ThreadsClient } from "@/lib/threads-client";
import { analyzeEngagement } from "@/lib/engagement-analyzer";
interface PageProps {
params: { username: string };
}
export default async function ProfilePage({ params }: PageProps) {
const client = new ThreadsClient(process.env.THREADS_API_KEY!);
const [user, posts] = await Promise.all([
client.getUser(params.username),
client.getUserPosts(params.username),
]);
const metrics = analyzeEngagement(user, posts);
return (
<main>
<h1>{user.full_name}</h1>
<p>@{user.username}</p>
<p>{user.follower_count.toLocaleString()} followers</p>
<p>Engagement rate: {metrics.engagement_rate}%</p>
</main>
);
}
Because this is a server component, the API key never reaches the browser. The ThreadsClient runs entirely on the server at request time.
Express
// routes/profile.ts
import { Router } from "express";
import { ThreadsClient, ThreadsApiError } from "../lib/threads-client.js";
const router = Router();
const client = new ThreadsClient(process.env.THREADS_API_KEY!);
router.get("/:username", async (req, res) => {
const { username } = req.params;
try {
const user = await client.getUser(username);
res.json({ success: true, data: user });
} catch (error) {
if (error instanceof ThreadsApiError) {
res.status(error.statusCode).json({
success: false,
error: error.message,
});
return;
}
res.status(500).json({ success: false, error: "Internal server error" });
}
});
export default router;
The ThreadsApiError class carries the original HTTP status code, so you can forward it directly to the client rather than always returning 500 for API failures.
Next Steps
This guide covered the core patterns for threads api typescript development: typed interfaces, a reusable client, array transformations with full type inference, discriminated union error handling, and framework integration. The same approach scales to more complex use cases — rate limiting, caching, pagination, and webhook processing.
Continue with these related guides:
- Getting Started with the Threads API — REST fundamentals, authentication, and your first request
- Threads API Python Tutorial — equivalent patterns in Python for data science workflows
- Track Threads Engagement Metrics — building dashboards on top of the data structures defined here
To explore the full endpoint surface and test requests interactively, use the thredly API playground on RapidAPI. All endpoints shown in this guide are available there with live response previews.