Developers waste minutes watching TypeScript compile before every Lambda deploy, even though the code never runs with types. By swapping ts-node for a combined esbuild + tsc --noEmit pipeline, you keep full type safety and slash build time. The result is a Lambda that talks to Claude with zero‑runtime overhead.
When you write a Lambda in TypeScript you usually run ts-node (a tool that compiles on‑the‑fly) during local testing and then run tsc (the TypeScript compiler) as a separate step before packaging. Two things happen:
type‑only imports stay in the bundle, adding bytes that the Lambda never uses.
Think of the process like a chef who first tastes every ingredient, then cooks the whole dish again from scratch. The taste test is useful, but doing it twice eats time and resources.
In plain English – the traditional flow makes the build slower without giving you any extra runtime benefit.
// src/handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { Anthropic } from "@anthropic-ai/sdk";
export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
const body = JSON.parse(event.body ?? "{}");
const diff = body.diff as string; // type‑only check, but stays in bundle
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const response = await client.completions.create({
model: "claude-3-sonnet-20240229",
prompt: `Review this diff and suggest improvements:\n${diff}`,
max_tokens: 512,
});
return {
statusCode: 200,
body: JSON.stringify({ comment: response.completion }),
};
};
What you would normally do
npx tsc --noEmit
npx esbuild src/handler.ts --bundle --platform=node --target=node22.0 --outfile=dist/handler.js
zip -j lambda.zip dist/handler.js
aws lambda update-function-code --function-name ReviewLambda --zip-file fileb://lambda.zip
Two separate commands, two passes, and the final handler.js still contains the type‑only import for APIGatewayProxyEvent. The compile step becomes a noticeable delay in CI/CD pipelines.
esbuild is a fast bundler written in Go; it can also strip type‑only imports automatically. By running tsc with --noEmit first, we let the TypeScript compiler do what it does best—verify that every variable matches its declared shape—without producing any JavaScript files. Then we hand the same source files to esbuild, which creates a tiny, ready‑to‑run bundle.
tsc --noEmit stops after the import type { … } statements, shrinking the bundle.
Key takeaway – you keep the type safety you love while letting esbuild do the heavy lifting of creating the final artifact.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": true,
"noEmit": true, // <-- important: do not write .js files
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
{
"scripts": {
"type-check": "tsc",
"bundle": "esbuild src/handler.ts --bundle --platform=node --target=node22.0 --outfile=dist/handler.js --experimental-strip-types",
"build": "npm run type-check && npm run bundle"
}
}
The --experimental-strip-types flag tells esbuild to delete any leftover type annotations that might have survived the bundling step (more on that later).
npm run build
// package.json (relevant part)
{
"name": "claude-lambda",
"version": "1.0.0",
"type": "module",
"scripts": {
// Verify types, then produce a tiny bundle
"type-check": "tsc",
"bundle": "esbuild src/handler.ts \\
--bundle \\
--platform=node \\
--target=node22.0 \\
--outfile=dist/handler.js \\
--experimental-strip-types",
"build": "npm run type-check && npm run bundle"
},
"dependencies": {
"@anthropic-ai/sdk": "^1.2.0",
"@aws-sdk/client-lambda": "^3.600.0"
},
"devDependencies": {
"esbuild": "^0.21.0",
"typescript": "^5.4.5"
}
}
Explanation
type-check runs the compiler without writing files.
bundle calls esbuild with the --experimental-strip-types flag (more in the next section).
build chains the two, guaranteeing that you never ship code that failed type‑checking.
Even though we asked tsc not to emit JavaScript, some type‑only imports can slip into the final bundle if we’re not careful. For example, a statement like import { type Request } from "./types" is removed by esbuild, but a value‑side import that only contains types can be mistakenly kept if the code references it in a way the bundler thinks is a runtime use.
satisfies operator as a safety net
The satisfies operator (added in TypeScript 4.9) lets you assert that a value matches a given type without changing the inferred type of the value. When you write:
const payload = {
diff: event.body?.diff ?? "",
} satisfies ReviewRequest;
payload has the shape expected by the Claude SDK.
// src/types.ts
export interface ReviewRequest {
/** The raw git diff that needs a review */
diff: string;
}
// src/handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { Anthropic } from "@anthropic-ai/sdk";
import type { ReviewRequest } from "./types"; // type‑only import, will be stripped
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// Parse incoming JSON safely
const body = JSON.parse(event.body ?? "{}");
// Use `satisfies` to make sure the shape matches ReviewRequest
const request = {
diff: body.diff ?? "",
} satisfies ReviewRequest; // <-- compile‑time only, removed later
// -----------------------------------------------------------------
// The rest of the function talks to Claude – see next section
// -----------------------------------------------------------------
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const response = await client.completions.create({
model: "claude-3-sonnet-20240229",
prompt: `Please review the following diff and suggest improvements:\n${request.diff}`,
max_tokens: 512,
});
return {
statusCode: 200,
body: JSON.stringify({ comment: response.completion }),
};
};
Tip – satisfies is perfect for validating request payloads that come from the outside world (API Gateway, SQS, etc.) because it does not affect the runtime value.
If you accidentally wrote:
import { ReviewRequest } from "./types"; // not `type` import
esbuild would keep the import, increasing bundle size, and the code would try to require a file that only contains TypeScript interfaces, causing a runtime error in Lambda. Using type imports or the satisfies pattern prevents that silent breakage.
Now that the build pipeline is fast and lean, let’s focus on the actual work: sending a diff to Claude (Anthropic’s LLM) and returning a comment.
The @anthropic-ai/sdk package ships with full TypeScript definitions. When you call client.completions.create, the compiler can verify that you provide every required field (model, prompt, max_tokens, …). That prevents a costly API error that would otherwise appear only after the Lambda runs.
// src/handler.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { Anthropic } from "@anthropic-ai/sdk";
import type { ReviewRequest } from "./types";
/**
* Lambda entry point.
* Receives a JSON body `{ "diff": "...git diff..." }`,
* asks Claude for a review, and returns `{ "comment": "..." }`.
*/
export const handler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
// 1️⃣ Guard against missing body
if (!event.body) {
return { statusCode: 400, body: JSON.stringify({ error: "No body" }) };
}
// 2️⃣ Parse and validate payload using `satisfies`
const raw = JSON.parse(event.body);
const payload = {
diff: raw.diff ?? "",
} satisfies ReviewRequest;
// 3️⃣ Prepare the Anthropic client – reads API key from environment
const anthropic = new Anthropic({
// The SDK expects a plain string; we assert its existence at runtime
apiKey: process.env.ANTHROPIC_API_KEY!,
});
// 4️⃣ Build the prompt – keep it short to stay within token limits
const prompt = `You are a code reviewer bot. Review this diff and suggest any improvements or fixes.\n\n${payload.diff}`;
// 5️⃣ Call Claude – type‑checked arguments
const completion = await anthropic.completions.create({
model: "claude-3-sonnet-20240229", // model name must be exact
max_tokens: 512, // limit response size for cost control
prompt,
});
// 6️⃣ Return the comment as JSON
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment: completion.completion }),
};
};
| Line | Reason |
|---|---|
import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda" |
Types that describe the shape of the incoming request and outgoing response; they disappear after bundling. |
import { Anthropic } from "@anthropic-ai/sdk" |
The real client that will make HTTP calls to Claude. |
type ReviewRequest import |
Only used for compile‑time checks; removed by esbuild. |
if (!event.body) … |
Defensive programming – Lambda should return a clear 400 when the caller forgets to send data. |
payload satisfies ReviewRequest |
Guarantees the object matches the expected interface without emitting extra code. |
new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }) |
Reads the secret from Lambda env vars; the ! tells TypeScript we are sure it exists (otherwise it would be `string |
{% raw %} prompt construction |
Simple string interpolation; you could add more context here if you like. |
anthropic.completions.create |
Typed call – the compiler warns if a required field is missing. |
return { … } |
Sends back JSON with the comment. The Content-Type header is required for API Gateway to treat it as JSON. |
require('esm') in a layer silently fails. The fix is to avoid the layer or switch to an ES‑module‑compatible version of the SDK.
In plain English – the code above stays within the safe zone of both SDKs: it uses only ES‑module imports, reads secrets from environment variables, and respects API limits.
Running a Lambda that talks to an external LLM can be expensive if you don’t know how often it’s invoked or how long Claude takes to respond. Adding heavy‑weight monitoring libraries defeats the purpose of a tiny bundle. Instead, we can use Node’s built‑in diagnostics_channel to emit lightweight events that CloudWatch can capture without adding code size.
diagnostics_channel?
A core module that lets you create a named channel and publish arbitrary data. Other parts of your system (or a CloudWatch subscription) can listen and log it. Because it’s built into Node, there is no extra dependency.
// src/metrics.ts
import { channel } from "node:diagnostics_channel";
/**
* A channel named "claude-lambda" that emits timing info.
* Listeners can subscribe to this channel in CloudWatch Logs Insights.
*/
export const claudeChannel = channel("claude-lambda");
// Helper to measure async functions
export async function withTiming<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = Date.now();
try {
const result = await fn();
return result;
} finally {
const durationMs = Date.now() - start;
// Emit an object – listeners can filter by `label`
claudeChannel.publish({ label, durationMs });
}
}
Now wrap the Claude call:
import { withTiming } from "./metrics";
// inside handler
const completion = await withTiming("anthropic-call", async () => {
return anthropic.completions.create({
model: "claude-3-sonnet-20240229",
max_tokens: 512,
prompt,
});
});
Key takeaway – you get millisecond‑level visibility without pulling in a big monitoring SDK, keeping the bundle under 100 KB.
"claude-lambda" JSON.
fields @timestamp, @message
| filter @message like /claude-lambda/
| parse @message "*label\":\"*\",*durationMs\":*}" as label, duration
| stats avg(duration) as avgMs, count() as calls by label
You’ll see average latency per label, letting you spot spikes in Claude response time.
You now have a repeatable pattern for building ultra‑fast, type‑safe Lambdas that call Claude.
By following these steps you cut build minutes, shrink Lambda zip size, and keep the safety net of TypeScript—all while getting valuable code‑review suggestions from Claude in real time. Happy coding!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-16 · Primary focus: TypeScriptBuild
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.