Setting up typescript strict null checks llm api clients forces you to confront that most response fields are optional in practice, even when provider types claim otherwise. A missing finish_reason or tool_calls entry will crash a naive handler at runtime, and strict mode makes the compiler refuse to look away.
1. Audit the actual wire format before trusting SDK types
Provider SDKs ship types that predate features or omit fields that appear only with certain parameters. Pull a raw response and inspect it before writing a line of client code.
curl -s https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' \
| jq '.choices[0]'
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello!",
"refusal": null
},
"finish_reason": "stop"
}
Note refusal is present but null, and logprobs is absent entirely. The official TypeScript types mark logprobs as optional, but refusal may be typed as string | null non-optional. Under strict null checks, string | null is not assignable to string without a guard. The SDK lied by being too precise about nullability.
2. Declare your own boundary types
Don’t let third-party types leak into your domain. Define a minimal interface that reflects what you actually consume, not what the provider might send in a future version.
interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: ToolCall[];
refusal?: string | null;
}
interface Choice {
index: number;
message: ChatMessage;
finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null;
logprobs?: LogProbs | null;
}
Enable strictNullChecks explicitly if you are not running full strict:
// tsconfig.json
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true,
"exactOptionalPropertyTypes": true
}
}
exactOptionalPropertyTypes adds another layer: it forbids assigning undefined to a property that is merely optional, which matches JSON where the key is missing entirely.
3. Narrow with user-defined type guards
Optional tool_calls breaks the moment you map over it. Write guards instead of ! assertions.
function hasToolCalls(msg: ChatMessage): msg is ChatMessage & { tool_calls: ToolCall[] } {
return Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
}
const choice = response.choices[0];
if (hasToolCalls(choice.message)) {
for (const call of choice.message.tool_calls) {
// call.function.name is narrowed to string
dispatch(call.function.name, call.function.arguments);
}
} else if (choice.message.content) {
render(choice.message.content);
}
Avoid choice.message.tool_calls!.length. The non-null assertion silences the compiler but defeats the purpose of typescript strict null checks llm api safety. If the provider returns null instead of an array, the bang operator throws at runtime.
4. Handle streaming chunks as partials
Streaming returns delta objects where most fields are absent until the final chunk. Type the delta separately from the finalized message.
interface Delta {
role?: string;
content?: string;
tool_calls?: Array<Partial<ToolCall> & { index: number }>;
}
interface Chunk {
id: string;
choices: Array<{ delta: Delta; finish_reason?: string | null }>;
}
Accumulate defensively:
let content = "";
const toolBuffer = new Map<number, Partial<ToolCall>>();
for await (const chunk of stream) {
const choice = chunk.choices[0];
if (!choice) continue; // usage-only chunk
const delta = choice.delta;
if (delta.content) content += delta.content;
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const prev = toolBuffer.get(tc.index) ?? {};
toolBuffer.set(tc.index, { ...prev, ...tc });
}
}
}
The optional chaining on chunk.choices[0] is mandatory; the provider may send a final usage chunk with an empty choices array. Strict mode catches this only if you typed choices as Array<...> and not any.
5. Normalize gateway and fallback responses
When you route through an OpenAI-compatible gateway like n4n.ai, the response shape is normalized to the OpenAI schema but still includes optional fields from underlying providers, and automatic fallback can mean the model field differs from your request. Type the wrapper with that reality.
interface CompletionResponse {
id: string;
model?: string;
choices: Choice[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
If you branch on response.model, check it: if (response.model?.startsWith("gpt")). Don’t assume it equals the request body. The same applies to system_fingerprint, which may be absent when a fallback provider handles the request.
6. Pitfalls: excess property and any leakage
Strict null checks don’t help if you cast to any at the edge. A common mistake is mapping provider responses through JSON.parse with an implicit any, then assigning to a typed variable.
const data = JSON.parse(raw) as CompletionResponse; // unsafe cast
If the provider adds a field, excess property checks won’t catch it at the as boundary. Use a validation library (zod, arktype) or a hand-rolled checker for production paths.
Another pitfall: optional properties in discriminated unions. If you write:
type Finish =
| { reason: "stop" }
| { reason: "length"; truncated: boolean };
But the API returns reason: "length" without truncated, strict checks will flag the missing field only when you try to read it. Define the union with truncated?: boolean to avoid false precision that forces you to lie with defaults.
7. Tradeoffs: verbosity versus runtime cost
Adding guards and explicit optionals increases lines of code. For a one-off script, you might disable strictNullChecks locally with a // @ts-nocheck or a relaxed tsconfig. For a typed LLM API client shipped to multiple teams, the compiler errors pay for themselves by catching missing tool_calls handling in code review.
A middle ground: use unknown at the network edge, validate, then narrow to exact types. This keeps the unsafe code isolated to one file and lets the rest of the app stay strict.
function parseCompletion(raw: unknown): CompletionResponse {
if (typeof raw !== "object" || raw === null) throw new Error("not object");
const obj = raw as Record<string, unknown>;
if (!Array.isArray(obj.choices)) throw new Error("no choices");
// ... further checks
return obj as CompletionResponse;
}
8. Test with synthetic nulls
Write unit tests that feed your parser the awkward shapes: content: null, missing finish_reason, tool_calls: null. Under strict mode, the test file must compile, which means your guards are exercised.
const sample: unknown = {
id: "x",
choices: [{ index: 0, message: { role: "assistant", content: null }, finish_reason: null }]
};
const parsed = parseCompletion(sample);
expect(parsed.choices[0].message.content).toBeNull();
If you skip this, the first production null will surface as a TypeError despite your “strict” setup.
9. Ordered checklist
- Enable
strictNullChecks(and preferablyexactOptionalPropertyTypes) in tsconfig. - Replace provider types with your own boundary interfaces reflecting observed JSON.
- Mark every field that can be missing or null as
?:or| null—never assume presence. - Write type guards for arrays and nested objects instead of using
!. - Use optional chaining on streaming deltas and empty
choicesarrays. - Validate at the JSON boundary; don’t trust
ascasts fromany. - Add tests that inject null and missing fields.
- Run
tsc --noEmitand fix everypossibly undefinederror before shipping.
Following this path makes typescript strict null checks llm api integrations boring—in the best way. The compiler now proves you handled the absent logprobs, the null refusal, and the late-arriving tool_calls before a single request hits production.