DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

How to Build a Lightning‑Fast TypeScript Lambda that Calls Claude, Using esbuild and tsc --noEmit

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.

Why the Traditional ts-node/tsc Build Is a Bottleneck

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:

  1. Two passes over the same source – tsc checks types, then ts-node (or a bundler) rewrites the code again.
  2. Full source files are shipped – even 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.

Minimal Example of the Traditional Flow

// 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 }),
  };
};
Enter fullscreen mode Exit fullscreen mode

What you would normally do

# 1️⃣ Type‑check
npx tsc --noEmit

# 2️⃣ Bundle (often with esbuild or webpack)
npx esbuild src/handler.ts --bundle --platform=node --target=node22.0 --outfile=dist/handler.js

# 3️⃣ Zip and upload to Lambda
zip -j lambda.zip dist/handler.js
aws lambda update-function-code --function-name ReviewLambda --zip-file fileb://lambda.zip
Enter fullscreen mode Exit fullscreen mode

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.

Combining esbuild with tsc --noEmit for Type‑Safe Bundles

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.

Why this works

  • tsc --noEmit stops after the type‑checking phase, so you still get all the safety guarantees.
  • esbuild reads the same files, sees the already‑validated types, and produces JavaScript in a fraction of the time.
  • The pipeline removes dead code such as 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.

Step‑by‑step Setup

  1. Create a tsconfig.json that tells tsc to only check.
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "noEmit": true,               // <-- important: do not write .js files
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"]
}
Enter fullscreen mode Exit fullscreen mode
  1. Add an npm script that runs both tools.
{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

The --experimental-strip-types flag tells esbuild to delete any leftover type annotations that might have survived the bundling step (more on that later).

  1. Run the single npm run build command. You’ll see a type‑check first, then a lightning‑fast bundling step that finishes in under a second for a typical Lambda.

Complete Code Block Showing the Build Script

// 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Stripping Types at Runtime with --experimental-strip-types

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.

The 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;
Enter fullscreen mode Exit fullscreen mode
  • Why: You get compile‑time validation that payload has the shape expected by the Claude SDK.
  • How: Because the operator does not emit any code, the check disappears after the --experimental-strip-types step, leaving zero runtime overhead.

Full Example with satisfies

// 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 }),
  };
};
Enter fullscreen mode Exit fullscreen mode

Tipsatisfies is perfect for validating request payloads that come from the outside world (API Gateway, SQS, etc.) because it does not affect the runtime value.

What can go wrong without it?

If you accidentally wrote:

import { ReviewRequest } from "./types"; // not `type` import
Enter fullscreen mode Exit fullscreen mode

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.

Calling Claude from a Lambda with Type‑Safe SDK Commands

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.

Why a typed SDK matters

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.

Full Lambda Code (ready to copy)

// 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 }),
  };
};
Enter fullscreen mode Exit fullscreen mode

Explanation of each line

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.

Gotchas specific to the SDKs

  • @anthropic-ai/sdk expects the model name exactly as listed in the console; a typo results in a 400 response that shows up as an unhandled exception in Lambda.
  • @aws-sdk/client-lambda (if you later need to invoke other Lambdas) runs into a Node 22 issue where 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.

Observability with diagnostics_channel and Zero‑Cost Metrics

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.

What is 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.

Adding a simple timer

// 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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
});
Enter fullscreen mode Exit fullscreen mode

Key takeaway – you get millisecond‑level visibility without pulling in a big monitoring SDK, keeping the bundle under 100 KB.

Hooking the channel into CloudWatch (quick tip)

  1. Create a CloudWatch Log Group for the Lambda.
  2. Set a subscription filter that matches "claude-lambda" JSON.
  3. In CloudWatch Insights run:
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
Enter fullscreen mode Exit fullscreen mode

You’ll see average latency per label, letting you spot spikes in Claude response time.

The Takeaway

You now have a repeatable pattern for building ultra‑fast, type‑safe Lambdas that call Claude.

  • Run tsc --noEmit first; it guarantees the code matches all declared types without writing files.
  • Hand the same sources to esbuild with --experimental-strip-types; the bundle is tiny and runs without any type‑related overhead.
  • Use the satisfies operator to validate request shapes while keeping the runtime code clean.
  • The @anthropic-ai/sdk client works smoothly once you feed it a properly typed request and a correct model name.
  • Capture lightweight observability data with diagnostics_channel to avoid bulky monitoring dependencies.
  • Remember the SDK‑specific gotchas: avoid require('esm') in Node 22 layers and be mindful of API‑gateway response headers for streaming.

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.

Top comments (0)