n4nAI

Strict null checks for optional LLM API fields

Practical guide to TypeScript strict null checks for optional LLM API fields: build typed clients, avoid pitfalls, and handle missing data safely.

n4n Team3 min read753 words

Audio narration

Coming soon — every post will get a voice note here.

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

  1. Enable strictNullChecks (and preferably exactOptionalPropertyTypes) in tsconfig.
  2. Replace provider types with your own boundary interfaces reflecting observed JSON.
  3. Mark every field that can be missing or null as ?: or | null—never assume presence.
  4. Write type guards for arrays and nested objects instead of using !.
  5. Use optional chaining on streaming deltas and empty choices arrays.
  6. Validate at the JSON boundary; don’t trust as casts from any.
  7. Add tests that inject null and missing fields.
  8. Run tsc --noEmit and fix every possibly undefined error 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.

Tagstypescriptstrict-modetypesllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All typescript typed llm api clients posts →