+ Integration Guide

· Updated August 28, 2026

Twitter/X API with TypeScript: Complete Guide

How to search X/Twitter and scrape tweets using TypeScript with full type safety. Working examples with typed responses for every FetchLayer endpoint.

Written by Alex P.

  • TypeScript
  • twitter scraping
  • X API
  • Twitter API
  • API integration
  • type safety

This guide shows you how to use the FetchLayer Twitter/X API with TypeScript. You get full type safety on API responses with zero external dependencies.

If you want the official TypeScript package instead of defining the response types yourself, FetchLayer now ships @fetchlayer/twitter. It includes typed response interfaces for all 10 endpoints and is open source on GitHub.


Setup

  1. Get a free API key (no credit card)
  2. TypeScript 5+ and Node 18+ (or Bun/Deno)

Optional, if you want the official SDK with built-in endpoint types:

npm install @fetchlayer/twitter

Type Definitions

Define the response types for the endpoints you’ll use:

interface TwitterAuthor {
  handle: string;
  displayName: string;
  isVerified: boolean;
  followersCount?: number;
}

interface Tweet {
  id: string;
  text: string;
  author: TwitterAuthor;
  createdAt: string;
  likeCount: number;
  retweetCount: number;
  replyCount: number;
  quoteCount?: number;
  viewCount?: number;
  url: string;
  lang?: string;
}

interface SearchResponse {
  results: Tweet[];
  cursor?: string;
}

interface TweetDetailResponse {
  id: string;
  text: string;
  author: TwitterAuthor;
  createdAt: string;
  likeCount: number;
  retweetCount: number;
  replyCount: number;
  quoteCount: number;
  viewCount: number;
  url: string;
  lang: string;
}

interface TweetRepliesResponse {
  replies: Tweet[];
  cursor?: string;
}

interface UserProfile {
  handle: string;
  displayName: string;
  description: string;
  isVerified: boolean;
  followersCount: number;
  followingCount: number;
  tweetsCount: number;
  joinedAt: string;
  location?: string;
  website?: string;
  avatarUrl: string;
  bannerUrl?: string;
}

interface UserTweetsResponse {
  tweets: Tweet[];
  cursor?: string;
}

interface AccountEntry {
  handle: string;
  displayName: string;
  isVerified: boolean;
  followersCount: number;
  description?: string;
}

interface FollowersResponse {
  accounts: AccountEntry[];
  cursor?: string;
}

Typed API Client

const API_KEY = process.env.FETCHLAYER_API_KEY!;
const BASE_URL = 'https://api.fetchlayer.dev/twitter';

async function twitter<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
  const res = await fetch(`${BASE_URL}/${endpoint}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    throw new Error(`FetchLayer ${res.status}: ${await res.text()}`);
  }

  return res.json() as Promise<T>;
}

Search Twitter/X

const data = await twitter<SearchResponse>('search', {
  query: 'best CI/CD tools',
  product: 'Top',
  count: 10,
});

// Full type safety — TypeScript knows data.results is Tweet[]
for (const tweet of data.results) {
  console.log(`[${tweet.likeCount} likes] @${tweet.author.handle}: ${tweet.text.slice(0, 100)}`);
}

// Paginate
if (data.cursor) {
  const page2 = await twitter<SearchResponse>('search', {
    query: 'best CI/CD tools',
    product: 'Top',
    count: 10,
    cursor: data.cursor,
  });
}

Get a Tweet by ID

const tweet = await twitter<TweetDetailResponse>('tweet-detail', {
  tweetId: '1942939879222220800',
});

console.log(`@${tweet.author.handle} (${tweet.author.followersCount?.toLocaleString()} followers)`);
console.log(`${tweet.likeCount} likes · ${tweet.retweetCount} retweets · ${tweet.replyCount} replies`);

Get Replies to a Tweet

const data = await twitter<TweetRepliesResponse>('tweet-replies', {
  tweetId: '1942939879222220800',
});

const highEngagementReplies = data.replies.filter(r => (r.likeCount ?? 0) > 10);
console.log(`${highEngagementReplies.length} replies with 10+ likes`);

Get a User Profile

const profile = await twitter<UserProfile>('user-profile-details', {
  handle: 'openai',
});

console.log(`${profile.displayName} (@${profile.handle})`);
console.log(`Followers: ${profile.followersCount.toLocaleString()}`);
console.log(`Joined: ${new Date(profile.joinedAt).getFullYear()}`);
console.log(`Bio: ${profile.description}`);

Get a User’s Tweets

const data = await twitter<UserTweetsResponse>('user-tweets', {
  handle: 'rauchg',
  count: 20,
});

const topTweets = (data.tweets ?? [])
  .sort((a, b) => (b.likeCount ?? 0) - (a.likeCount ?? 0))
  .slice(0, 5);

for (const tweet of topTweets) {
  console.log(`[${tweet.likeCount} likes] ${tweet.text.slice(0, 120)}`);
}

Get Followers and Following

// Who a user follows
const following = await twitter<FollowersResponse>('following', {
  handle: 'openai',
  count: 50,
});

for (const account of following.accounts ?? []) {
  console.log(`@${account.handle} — ${account.followersCount.toLocaleString()} followers`);
}

// Verified followers
const verified = await twitter<FollowersResponse>('verified-followers', {
  handle: 'openai',
  count: 20,
});
console.log(`${verified.accounts?.length ?? 0} verified followers`);

Full Example: Audience Analysis

async function analyzeAudience(handle: string) {
  const profile = await twitter<UserProfile>('user-profile-details', { handle });

  // Get recent tweets and their engagement
  const tweets = await twitter<UserTweetsResponse>('user-tweets', { handle, count: 50 });
  const avgLikes = (tweets.tweets ?? []).reduce((sum, t) => sum + (t.likeCount ?? 0), 0) / (tweets.tweets?.length || 1);
  const avgRetweets = (tweets.tweets ?? []).reduce((sum, t) => sum + (t.retweetCount ?? 0), 0) / (tweets.tweets?.length || 1);

  console.log(`${profile.displayName} (@${profile.handle})`);
  console.log(`Followers: ${profile.followersCount.toLocaleString()}`);
  console.log(`Avg engagement: ${avgLikes.toFixed(0)} likes, ${avgRetweets.toFixed(0)} retweets per tweet`);
  console.log(`Engagement rate: ${((avgLikes / profile.followersCount) * 100).toFixed(2)}%`);
}

analyzeAudience(process.argv[2] || 'openai');

Handling Errors and Rate Limits

The typed client above throws a generic error for any non-OK response. FetchLayer returns standard HTTP status codes: 401 for a missing or invalid API key, 400 when a required field like query or a handle is missing, and 429 once you exceed your plan’s request rate — worth its own error type so callers can catch and branch on it with instanceof:

class RateLimitError extends Error {}

async function twitter<T>(endpoint: string, body: Record<string, unknown>, retries = 2): Promise<T> {
  for (let attempt = 0; attempt <= retries; attempt++) {
    const res = await fetch(`${BASE_URL}/${endpoint}`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });

    if (res.status === 429) {
      if (attempt === retries) throw new RateLimitError('Rate limited — back off and try again later');
      const wait = Number(res.headers.get('Retry-After') ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (res.status === 401) throw new Error('Invalid or missing API key');
    if (res.status === 400) throw new Error(`Bad request to /${endpoint}: ${await res.text()}`);
    if (!res.ok) throw new Error(`FetchLayer ${res.status}: ${await res.text()}`);

    return res.json() as Promise<T>;
  }
  throw new RateLimitError('Rate limited — back off and try again later');
}

A 429 mid-run is expected behavior on the free tier, not a bug — the retry above respects Retry-After rather than hammering the endpoint again immediately. Catching RateLimitError separately from a plain Error lets a caller back off a rate limit differently from surfacing an auth or validation failure.


Narrowing optional fields with type guards

Tweet objects vary. A quoted tweet has fields a plain one doesn’t; a suspended author may come back sparse. Marking those fields optional is correct, but then every access needs narrowing — and ?. scattered through analysis code hides bugs rather than fixing them.

Type guards let you narrow once and work with a solid type afterwards:

interface TwitterUser {
  handle: string;
  displayName: string;
  followersCount?: number;
  verified?: boolean;
}

interface Tweet {
  id: string;
  text: string;
  author?: TwitterUser;
  likeCount?: number;
  retweetCount?: number;
  replyCount?: number;
}

// A tweet we can actually compute engagement on.
interface CompleteTweet extends Tweet {
  author: TwitterUser & { followersCount: number };
  likeCount: number;
  retweetCount: number;
  replyCount: number;
}

function isComplete(tweet: Tweet): tweet is CompleteTweet {
  return (
    typeof tweet.author?.followersCount === 'number' &&
    typeof tweet.likeCount === 'number' &&
    typeof tweet.retweetCount === 'number' &&
    typeof tweet.replyCount === 'number'
  );
}

const { results } = await twitter<{ results: Tweet[] }>('search', {
  query: 'typescript',
  product: 'Latest',
});

// One filter, and everything downstream is fully typed —
// no optional chaining, no `?? 0` defaults hiding missing data.
const usable = results.filter(isComplete);

const ranked = usable
  .map((t) => ({
    handle: t.author.handle,
    text: t.text,
    rate: ((t.likeCount + t.retweetCount + t.replyCount) / t.author.followersCount) * 100,
  }))
  .sort((a, b) => b.rate - a.rate);

The tweet is CompleteTweet return type is what makes this work: after .filter(isComplete), the compiler knows those fields exist. That’s meaningfully better than ?? 0 defaults, which silently turn “we don’t know” into “zero” and skew every average you compute afterwards.


Branded types for identifiers

Tweet IDs, user handles, and cursors are all strings, so the compiler will happily let you pass one where another belongs:

// Compiles fine. Fails at runtime.
await twitter('tweet-detail', { tweetId: 'rauchg' });

Branded types make those mistakes visible at compile time:

type Brand<T, B extends string> = T & { readonly __brand: B };

type TweetId = Brand<string, 'TweetId'>;
type Handle = Brand<string, 'Handle'>;

// Constructors are the only way in — validate while you're there.
function tweetId(raw: string): TweetId {
  if (!/^\d+$/.test(raw)) throw new Error(`Invalid tweet ID: ${raw}`);
  return raw as TweetId;
}

function handle(raw: string): Handle {
  const cleaned = raw.replace(/^@/, '');
  if (!/^\w{1,15}$/.test(cleaned)) throw new Error(`Invalid handle: ${raw}`);
  return cleaned as Handle;
}

async function getTweet(id: TweetId) {
  return twitter<Tweet>('tweet-detail', { tweetId: id });
}

async function getProfile(h: Handle) {
  return twitter<TwitterUser>('user-profile-details', { handle: h });
}

await getTweet(tweetId('1942939879222220800'));  // ✅
await getTweet(handle('rauchg'));                // ❌ Argument of type 'Handle'…

The handle() constructor also strips a leading @, which is the single most common cause of an empty profile response — users paste @rauchg, the API expects rauchg. Normalizing inside the constructor means it can only be done once and can’t be forgotten.

This is more ceremony than a small script needs. It earns its keep in a codebase where IDs get passed through several layers before reaching the request.


What’s Next