Claude can call your own APIs and return JSON, but developers often waste time writing runtime guards. TypeScript 5.5 introduces inferred type predicates that turn those guards into compile‑time guarantees. In this guide you’ll see how a single line of code can eliminate a whole class of bugs when parsing Claude’s tool responses.
Why Claude Tool Calls Need Strong Types
Claude (the model behind Amazon Bedrock’s “Claude” family) can act like a tiny micro‑service. You describe a tool – a JSON schema that tells Claude how to format its answer – and Claude will return an object that should match that schema. In practice, network hiccups, model quirks, or a simple typo in the prompt can produce a payload that is missing a field or has the wrong type.
If you treat the response as “just JSON” and only check it at runtime, you end up writing a lot of defensive code:
// A naïve way to parse Claude’s answer
function parseCompletion(raw: unknown): string[] {
// Assume the shape is { suggestions: string[] }
if (typeof raw !== "object" || raw === null) {
throw new Error("Not an object");
}
const maybe = (raw as any).suggestions;
if (!Array.isArray(maybe)) {
throw new Error("suggestions is not an array");
}
// Still have to convince TypeScript that every element is a string
return maybe.map((s) => {
if (typeof s !== "string") {
throw new Error("non‑string suggestion");
}
return s;
});
}
Why does this feel painful? Imagine you’re receiving a package. You could open the box, look at each item, and manually verify it matches the packing list. That works, but it’s slow and error‑prone. A stronger approach is to know the exact shape of the package before it arrives, so the moment it lands you can instantly confirm it’s correct.
In plain English: Without a compile‑time check, every Claude response forces you to write repetitive “is this what I expect?” code. That adds noise and still doesn’t protect you from mistakes that slip through at runtime.
The hidden cost in serverless environments
When you run this logic inside an AWS Lambda (a short‑lived function that scales automatically), every extra if statement consumes precious milliseconds and memory. On top of that, Bedrock has a few operational quirks you need to remember:
| Gotcha | What it means for you |
|---|---|
| Token limits per minute | The limit applies to the whole account, not per request. A burst of parallel Lambdas can hit the ceiling unexpectedly. |
| Streaming responses need manual SSE parsing | If you enable streaming, you must decode Server‑Sent Events yourself; the SDK does not do it for you. |
| Knowledge base sync delay | New documents can take several minutes before they become searchable, which can affect tool output that relies on a KB. |
| Model selection matters | Not every Claude model supports fine‑tuning; picking the wrong one leads to silent failures. |
| Cross‑region latency | Calls to a region far from your Lambda can add latency that varies under load. |
Knowing these constraints ahead of time helps you design a wrapper that guards both the shape of the data and the behaviour of the call.
TypeScript 5.5 Inferred Type Predicates in One Line
A type predicate is a special return type that tells TypeScript “if this function returns true, then the argument is of type X”. Before TS 5.5 you had to write the predicate manually:
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
TS 5.5 introduced inferred type predicates. When a generic function’s return type is written as value is T, the compiler can automatically infer T from the call site, provided the function body actually performs the necessary checks. The result is a single line that both checks and narrows the type.
// One‑liner inferred predicate – the compiler infers T from the call
function isShape<T>(value: unknown): value is T {
return typeof value === "object" && value !== null && // basic object check
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(Object.keys(value) as (keyof T)[]).every((k) => k in (value as T));
}
How does this work? The function accepts any unknown value, then:
- Verifies it’s a non‑null object.
- Checks that every key defined in the generic type
Tactually exists on the runtime object.
Because the return type is value is T, TypeScript treats a successful call as a compile‑time guarantee that value matches T. No extra if statements are needed after the call.
Key takeaway: The single line
return …does the heavy lifting; the compiler reads thevalue is Tannotation and automatically narrows the variable for you.
Analogy
Think of the inferred predicate like a passport control officer who already knows the exact list of items you’re allowed to bring. When you present your bag, the officer scans it once and instantly tells you “all good” or “stop”. You don’t have to manually inspect each item yourself.
Building a Typed Claude Tool Wrapper
Now that we have a way to assert shape with a single line, let’s build a small wrapper around the Bedrock SDK. The wrapper will:
- Call Claude using
@aws-sdk/client-bedrock. - Receive raw JSON.
- Run
parseToolResponse<T>which uses our inferred predicate. - Return the strongly‑typed result or throw a clear error.
First, install the SDKs (you’ll need them in the Lambda bundle):
npm install @aws-sdk/client-bedrock @aws-sdk/client-bedrock-runtime
The wrapper code
import {
BedrockClient,
InvokeModelCommand,
} from "@aws-sdk/client-bedrock";
import type { InvokeModelCommandInput } from "@aws-sdk/client-bedrock";
// The shape we expect from Claude when it suggests completions
export interface CompletionResponse {
/** Array of suggested code snippets */
suggestions: string[];
}
/**
* Generic function that validates a raw JSON payload against a TypeScript type.
*
* @param payload - The raw JSON returned by Claude (usually `unknown`).
* @returns The payload narrowed to T if it passes the predicate.
* @throws If the payload does not match the expected shape.
*/
export function parseToolResponse<T>(payload: unknown): T {
// One‑liner inferred predicate does the heavy lifting
if (!isShape<T>(payload)) {
throw new Error(`Invalid tool response: ${JSON.stringify(payload)}`);
}
// At this point TypeScript knows `payload` is T
return payload;
}
/**
* Calls Claude via Bedrock and parses the tool response.
*
* @param prompt - The user’s question or request.
* @returns Typed response defined by `T`.
*/
export async function callClaude<T>(prompt: string): Promise<T> {
// Create a Bedrock client – the SDK picks up credentials from the Lambda env
const client = new BedrockClient({ region: process.env.AWS_REGION });
// Build the model input – we embed the prompt inside the expected tool JSON
const input: InvokeModelCommandInput = {
modelId: "anthropic.claude-v2", // make sure the model supports tools
// The payload must be a stringified JSON object; Claude will return JSON.
body: JSON.stringify({
prompt,
// Example tool definition – Claude will return `suggestions` array
tool: {
name: "autocomplete",
input_schema: {
type: "object",
properties: {
suggestions: { type: "array", items: { type: "string" } },
},
required: ["suggestions"],
},
},
}),
contentType: "application/json",
accept: "application/json",
};
// Invoke the model – the SDK returns a streaming response, but we read it all at once
const command = new InvokeModelCommand(input);
const response = await client.send(command);
// Bedrock returns the model output in `body` as a Uint8Array; decode it
const rawJson = new TextDecoder().decode(response.body);
const parsed = JSON.parse(rawJson) as unknown;
// Use the generic parser to get a typed result
return parseToolResponse<T>(parsed);
}
Key points in the code:
-
isShape<T>is the inferred predicate we defined earlier. - The wrapper hides all the SDK boilerplate, leaving the caller with a typed promise.
- Errors are thrown early if the shape is wrong, so the Lambda never proceeds with bad data.
Tip: Keep the SDK client outside the function when possible (e.g., module‑level) to reuse connections across Lambda invocations and reduce cold‑start time.
Real‑World Example: Auto‑Complete Suggestions from Claude
Let’s put the wrapper to work inside a Lambda handler. The Lambda receives a piece of incomplete TypeScript code, asks Claude for suggestions, and returns the array of completions.
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { callClaude, CompletionResponse } from "./claudeWrapper";
/**
* Lambda entry point – receives a request with `codeFragment` and returns suggestions.
*
* Expected request body: { "codeFragment": "function add(a: number, b: number) {" }
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
try {
// Parse incoming JSON safely – this is a different guard, unrelated to Claude
const body = JSON.parse(event.body ?? "{}");
const fragment: string = typeof body.codeFragment === "string"
? body.codeFragment
: "";
if (!fragment) {
return {
statusCode: 400,
body: JSON.stringify({ error: "codeFragment is required" }),
};
}
// Build a prompt that tells Claude to use the `autocomplete` tool
const prompt = `
You are a TypeScript assistant. Given the following incomplete code, suggest up to three completions that would make the function compile.
\`\`\`ts
${fragment}
\`\`\`
Return ONLY JSON that matches the tool schema:
{
"suggestions": ["..."]
}
`;
// The generic argument tells TypeScript we expect a CompletionResponse
const result = await callClaude<CompletionResponse>(prompt);
// At this point `result` is guaranteed to have a `suggestions` array of strings
return {
statusCode: 200,
body: JSON.stringify({ suggestions: result.suggestions }),
};
} catch (err) {
console.error("Error processing request:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: (err as Error).message }),
};
}
};
Why does this feel safe?
- The prompt explicitly asks Claude to respect the tool schema.
-
callClaude<CompletionResponse>tells the wrapper the exact shape we need. - If Claude returns something malformed,
parseToolResponsethrows before we reach thereturnstatement, preventing downstream bugs.
Handling Bedrock’s streaming edge case
If you enable streaming (Accept: "text/event-stream"), the SDK will give you raw bytes that need manual Server‑Sent Events (SSE) parsing. The wrapper above avoids that by using the default non‑streaming mode, which is simpler for most Lambda use‑cases. Remember the gotcha: streaming adds complexity and can increase Lambda execution time if you have to buffer and parse events yourself.
In plain English: By declaring the expected shape once (
CompletionResponse) and letting TypeScript check it at compile time, you eliminate a whole class of runtime surprises.
Testing the Wrapper with ts-node/tsx
Before deploying, you can run the wrapper locally with ts-node (executes TypeScript directly) or tsx (supports ES modules and hot‑reloading). Here’s a quick script that simulates a request:
// test.ts – run with `npx ts-node test.ts` or `npx tsx test.ts`
import { callClaude, CompletionResponse } from "./claudeWrapper";
async function main() {
const prompt = `
You are a TypeScript assistant. Suggest three one‑line completions for the following code:
\`\`\`ts
function greet(name: string) {
// TODO
}
\`\`\`
Return ONLY JSON matching:
{
"suggestions": ["..."]
}
`;
try {
// The generic argument enforces the expected return shape
const response = await callClaude<CompletionResponse>(prompt);
console.log("✅ Got suggestions:", response.suggestions);
} catch (e) {
console.error("❌ Failed to parse Claude response:", (e as Error).message);
}
}
main();
When you run the script:
- The SDK picks up your AWS credentials from
~/.aws/credentials(or environment variables). - The
callClaudefunction returns a fully typed object or throws a clear error. - Because the type predicate runs before the value is used, TypeScript’s editor support will show you
response.suggestionsas astring[]without anyanywarnings.
Quick sanity check with filter(Boolean)
A common pattern after fetching multiple tool responses is to filter out undefined values:
const responses = [resp1, resp2, undefined].filter(Boolean);
Before TS 5.5, filter(Boolean) didn’t narrow the array type; the result was still (T | undefined)[], so you needed extra checks. With inferred predicates, the compiler does understand that Boolean removes falsy values, giving you a clean T[]. This saves you from writing another guard like if (r) ….
Tip: When you see
filter(Boolean)in older code, you may still need a cast. After upgrading to TS 5.5, you can drop that cast and let the compiler do the work.
The Takeaway
- Claude’s tool calls return JSON, which is easy to misuse without strict checks.
- TypeScript 5.5’s inferred type predicates let you write a single line that both validates and narrows a value to a generic type.
- A small wrapper around
@aws-sdk/client-bedrockcan hide SDK details, enforce the tool schema, and surface errors before your Lambda proceeds. - Real‑world Lambdas benefit from compile‑time guarantees: fewer runtime
ifstatements, lower latency, and clearer error messages. - Known Bedrock quirks (token limits, streaming SSE, KB sync delay, model selection, cross‑region latency) should be accounted for when designing the wrapper.
- Testing with
ts-nodeortsxgives you instant feedback that the inferred predicate works as expected, and eliminates the need for extrafilter(Boolean)casts.
With these pieces in place, you can let Claude do the heavy lifting while your TypeScript code stays type‑safe, readable, and maintainable. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-10-30 · Primary focus: TypeScript55
All code blocks are intended to be correct and runnable, but please verify them
against the TypeScript docs before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)