The three directory sites I launched in April — Top AI Tools, Find Games Like, and Open Alternative To — all pull from external APIs that share one property: the response shape is either poorly documented, partially unofficial, or capable of returning unexpected values in production without any server-side change that I'd be notified about. See the original architecture overview for the full stack context.
I wrote thin TypeScript fetch clients for each: GitHub's REST API, Steam's Store Web API, and HuggingFace's model registry. None of them use an official SDK. Combined, they're 167 lines of TypeScript. All three taught me different things about how to type responses you don't fully control.
The conclusion up front
Being defensive with TypeScript interfaces for external APIs is the right default — meaning: mark uncertain fields as optional, return explicit null rather than throwing on missing data, and check for null before using values downstream. But there's a failure mode in the opposite direction: making everything optional silently swallows bugs that should be loud.
The balance I landed on: mark optional only where the API documentation or observed production behavior actually allows a missing value. Use string | null when I mean the key is present but might have a null value. Require only fields I've seen in every response. Use as casts only at the fetch boundary, never deeper. And treat differing success field shapes as a signal about API maturity.
Steam: two different success types for two endpoints
Steam's unofficial Web API has a success envelope pattern, but it's inconsistent in a way I didn't notice until I had both endpoints side by side.
The appdetails endpoint:
https://store.steampowered.com/api/appdetails?appids=578080&cc=us&l=en
Returns this shape when successful (HTTP 200 regardless of validity):
{
"578080": {
"success": true,
"data": { "name": "PLAYERUNKNOWN'S BATTLEGROUNDS", ... }
}
}
So success here is a boolean. My TypeScript for the response:
const data = (await res.json()) as Record<string, { success: boolean; data: SteamAppDetail }>;
const entry = data[String(appid)];
return entry?.success ? entry.data : null;
Now the review summary endpoint — completely different path:
https://store.steampowered.com/appreviews/{appid}?json=1&filter=summary&...
Returns:
{
"success": 1,
"query_summary": { ... }
}
Here success is the integer 1, not a boolean. The type I ended up with after seeing this:
const data = (await res.json()) as {
success: 1 | number;
query_summary?: { ... };
};
if (data.success !== 1 || !data.query_summary) return null;
success: 1 | number is a valid TypeScript type — it widens to number but documents that 1 is the expected success value. === 1 is a strict check that correctly rejects 0, 2, or any non-one integer. Using success: boolean here and checking data.success truthily would also pass for 1, but it would misrepresent the API contract and make the code look like it expects a boolean toggle.
This inconsistency — boolean for appdetails, integer-1 for appreviews — isn't documented anywhere. It's the kind of thing you find by running both endpoints and looking at the raw responses.
Steam: the envelope means HTTP 200 is not success
The correct null check on appdetails is entry?.success, not entry?.data:
const entry = data[String(appid)];
return entry?.success ? entry.data : null;
If you write return entry?.data ?? null, you get undefined on a { success: false } response, which looks like a missing field in your downstream code rather than a failed lookup. The distinction matters when you're inserting into Turso's libSQL — a row with name: undefined from a failed appdetails call looks different from a row that was never inserted.
The interface for SteamAppDetail has several optional fields based on observed production behavior, not documentation:
export interface SteamAppDetail {
name: string;
steam_appid: number;
short_description?: string;
about_the_game?: string;
genres?: { id: string; description: string }[];
categories?: { id: number; description: string }[];
release_date?: { coming_soon: boolean; date: string };
developers?: string[];
publishers?: string[];
header_image?: string;
price_overview?: { final_formatted?: string };
}
short_description is absent on some free-to-play games with no store description copy. price_overview is absent on free games entirely. header_image is absent on older games that predate Steam's image CDN. The nested final_formatted? inside price_overview? exists because the outer field is present on paid games but the inner formatted price can be absent during Steam sale processing.
Three levels of optional for what looks like one display string. This is what happens when you accept that the documentation doesn't describe all the cases.
HuggingFace: a dual-ID problem that looks like a convenience
The HuggingFace model registry API returns model objects with two identifier fields:
export interface HFModel {
id: string;
modelId?: string;
author?: string;
downloads?: number;
likes?: number;
tags?: string[];
pipeline_tag?: string;
library_name?: string;
createdAt?: string;
lastModified?: string;
}
In the listTopModels list response, id is the canonical identifier — e.g., "meta-llama/Llama-3.2-1B". modelId is an older field that can appear on models migrated from earlier versions of the hub. It may be the author-prefixed canonical, a shorthand name, or an alternate identifier. Both fields can coexist on the same object with different values.
My initial fetch code:
const id = m.modelId ?? m.id;
Wrong in two ways. First, ?? prefers modelId over id, which means I'd use the potentially-stale legacy identifier when it exists. Second, the nullish coalescing hides a case I hadn't thought through: when modelId is a different valid string, it wins silently. The code looked like a convenience fallback but was actually a silent identifier swap.
I changed it to:
const id = m.id;
The lesson: when an API returns two fields that appear to mean the same thing, pick one authoritatively. Don't let a ?? chain express an ambiguity you haven't resolved.
author and downloads are both marked optional. author can genuinely be absent for some organization uploads. downloads is technically always present in list API responses, but I mark it optional and default at insert time:
downloads: m.downloads ?? 0,
If the API changes to omit it, the ETL writes 0 to a NOT NULL INTEGER column rather than inserting undefined and failing the constraint. The sleep interval choices for this ETL apply the same principle to timing — start with what's safe, not what's optimal.
GitHub: optional versus nullable, and why the difference matters
The GitHub REST API is the most formally documented of the three, and that shows in how I typed it:
export interface GhRepo {
id: number;
name: string;
full_name: string;
html_url: string;
description: string | null;
stargazers_count: number;
forks_count: number;
language: string | null;
topics?: string[];
license?: { spdx_id: string } | null;
pushed_at: string;
created_at: string;
}
description: string | null — not description?: string. The GitHub REST API documentation explicitly specifies that description can be null — it's always present in the response object, it's just that repos without descriptions return null rather than omitting the key. TypeScript's optional modifier (?) means the key may be absent. string | null means the key is always present but its value may be null. These are different contracts.
Both require a null check before use:
const desc = repo.description ?? "No description";
But the TypeScript type documents the actual behavior. If GitHub changes the field to always return an empty string instead of null, removing | null would require fixing any remaining null checks — and the type system would tell me. With description?: string, I'd keep the null check forever without knowing it was no longer necessary.
license?: { spdx_id: string } | null is both optional and nullable because the GitHub docs say license can be omitted for certain repo types and can be present-but-null for unlicensed repos. The OSS alternatives directory ETL writes r.license?.spdx_id ?? null to Turso, which handles both the absent-field case and the null-value case with the same expression.
Where as casts live and why they're there
Every client has one as cast at the fetch boundary:
return (await res.json()) as GhRepo;
This is a structural lie — res.json() returns unknown, and I'm asserting shape without validation. The honest alternative is Zod or a similar runtime validator. I chose not to add it because the clients need zero runtime dependencies beyond the environment's built-in fetch, and the three-tier content quality ladder provides a downstream safety net: entries generated from malformed data get flagged as fallback-template and re-queued.
The as casts are all at the outer boundary — (await res.json()) as T. There are no as casts deeper in the codebase that reference these types. If the assertion is wrong, the failure surfaces at the first field access, not silently somewhere in application logic.
For the Steam review response, I went further and narrowed the type more tightly than a full as SteamApiReviewResponse:
const data = (await res.json()) as {
success: 1 | number;
query_summary?: { ... };
};
Inline assertion with a narrow type, because this endpoint's shape is unusual enough that I wanted the type to be visible at the call site rather than in a separate interface definition.
What I'd change with hindsight
Zod at the Steam fetch boundary. The success: boolean envelope in appdetails and the success: 1 | number integer in appreviews are the kind of inconsistency that Zod would surface during development rather than in production data. A Zod schema:
const SteamEnvelope = z.record(
z.union([
z.object({ success: z.literal(true), data: SteamAppDetailSchema }),
z.object({ success: z.literal(false) }),
])
);
That makes the success/failure discriminated union explicit rather than relying on a truthy check on a loosely-typed field.
I'd also add a branded timestamp type for pushed_at and created_at on GitHub repos:
type ISOTimestamp = string & { readonly _brand: "ISOTimestamp" };
These fields are always ISO 8601. A branded type makes that invariant checkable rather than assumed. Whether it's worth the verbosity for a six-month experiment is debatable — but for a long-running system where the timestamp format might matter for sorting or display, the type annotation pays for itself.
The pairwise model compare pages consume these typed objects downstream. Every optional field check in the UI component exists because of a corresponding optional in the interface. Get the interface wrong and the UI either throws or silently renders empty strings.
The practical boundary of TypeScript here
TypeScript interface accuracy for external APIs is bounded by how well you understand the actual API behavior, not the documentation. Steam's unofficial endpoints have no official docs. HuggingFace's dual ID exists without explanation in their schema reference. GitHub's license null behavior is documented but requires reading the object reference carefully.
The pattern that works: start with all fields optional, add non-null constraints and required markers as you observe production data over several weeks of ETL runs. The model_used column in the content quality ladder handles the output side of the same problem — content generated from wrong data is flagged and re-run. The interface design handles the input side.
For a statically-generated directory where data quality directly impacts page content, this pairing — cautious interfaces plus a re-run layer — is the right architecture. TypeScript won't stop a bad API response. But a well-typed interface will make the failure mode explicit rather than mysterious.
FAQ
Why not use the official Steam Steamworks SDK?
Valve's Steamworks SDK is for game developers shipping on Steam, not for third-party directories querying the public store. The server-side Web API endpoints (appdetails, appreviews, GetAppList) are community-documented unofficial APIs. There is no official Node.js SDK for them.
When does the as cast approach break down?
When the API returns a shape that's structurally compatible with your interface but semantically wrong. A field that's number but should be a specific range, or a string that's supposed to be an enum value. These pass the as cast silently. Zod's z.enum() or z.min()/z.max() validators would catch them.
What's the difference between optional (?) and | null in TypeScript interfaces?
optional means the key may be absent from the object. | null means the key is present but its value may be null. Both require a null-safe access before use, but they document different API contracts. Using the correct one matters when the API changes — if a previously-optional field becomes required, TypeScript will flag the now-unnecessary optional chaining at callsites.
Does success: 1 | number actually restrict the type to just 1?
No — 1 | number widens to number. It's documentation intent, not a type constraint. The check data.success !== 1 is still necessary and enforces it at runtime. For a stricter static guarantee, z.literal(1) in Zod would do it.
Related: Three sleep intervals for three APIs — the rate limit decisions behind the same clients described here.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)