n4nAI

LangChain.js LCEL chains in TypeScript

Build production-ready LCEL chains in TypeScript with streaming, branching, and error handling — runnable code at every step.

n4n Team4 min read781 words

Audio narration

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

LangChain Expression Language (LCEL) is the declarative way to compose chains in LangChain.js. It replaces the legacy Chain subclasses with a composable Runnable interface that handles streaming, batching, retries, and async execution out of the box. This guide walks through building langchain.js lcel typescript chains from a fresh project to a production-ready pipeline with streaming, parallel branches, and fallback logic.

Step 1: Initialize the project and install dependencies

Create a new Node project with TypeScript configured for ES modules — LCEL relies on top-level await and modern module resolution.

mkdir lcel-demo && cd lcel-demo
npm init -y
npm install langchain @langchain/openai @langchain/anthropic zod
npm install -D typescript tsx @types/node

Configure tsconfig.json for NodeNext modules and strict mode:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

Add a run script to package.json:

"scripts": {
  "dev": "tsx watch src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

Set your API keys in .env (never commit this):

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

Verify the setup runs:

npm run dev

You should see an empty run with no errors.

Step 2: Understand the Runnable interface

Every LCEL component implements Runnable<Input, Output>. The core methods are:

  • invoke(input) — single execution, returns Promise<Output>
  • stream(input) — returns AsyncIterable<Output> for token-by-token output
  • batch(inputs) — parallel execution for arrays of inputs
  • pipe(next) — composes two runnables, feeding output of first into input of second

Create src/runnables.ts to see the types:

import { Runnable, RunnableSequence, RunnablePassthrough } from "@langchain/core/runnables";

const addOne = Runnable.from<number, number>({
  invoke: async (n) => n + 1,
});

const double = Runnable.from<number, number>({
  invoke: async (n) => n * 2,
});

const chain = addOne.pipe(double);

const result = await chain.invoke(5); // 12
console.log(result);

Run it:

npx tsx src/runnables.ts

Output: 12. The pipe method returns a RunnableSequence that chains invocation automatically.

Step 3: Build a basic prompt → model → parser chain

The canonical LCEL pattern: prompt template → chat model → output parser. Create src/basic-chain.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "You are a concise technical writer."],
  ["human", "Explain {concept} in one paragraph for a {audience} audience."],
]);

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0.2,
});

const parser = new StringOutputParser();

const chain = prompt.pipe(model).pipe(parser);

const result = await chain.invoke({
  concept: "LCEL in LangChain.js",
  audience: "senior backend engineer",
});

console.log(result);

Run it:

npx tsx src/basic-chain.ts

You should see a coherent paragraph. The chain handles prompt formatting, model invocation, and string extraction in one typed pipeline.

Step 4: Add streaming for real-time output

Streaming is where LCEL shines. The same chain works with stream() — each chunk is a partial string. Create src/streaming.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = ChatPromptTemplate.fromTemplate(
  "Write a haiku about {topic}."
);

const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0.7,
  streaming: true,
});

const parser = new StringOutputParser();

const chain = prompt.pipe(model).pipe(parser);

console.log("Streaming haiku:\n");

for await (const chunk of await chain.stream({ topic: "TypeScript types" })) {
  process.stdout.write(chunk);
}

console.log("\n--- done ---");

Run it:

npx tsx src/streaming.ts

Tokens appear as they arrive. The AsyncIterable yields string deltas — no buffering, no extra code. This works identically for Anthropic models; swap the import and model name.

Step 5: Branch with RunnableParallel for multi-step logic

Real pipelines often need multiple sub-chains running in parallel. RunnableParallel (imported as RunnableMap in older versions) fans out to multiple runnables and merges results into an object. Create src/parallel.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableParallel } from "@langchain/core/runnables";

const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.3 });

const summaryPrompt = ChatPromptTemplate.fromTemplate(
  "Summarize this in one sentence: {text}"
);
const tagsPrompt = ChatPromptTemplate.fromTemplate(
  "Extract 3 comma-separated tags from: {text}"
);
const sentimentPrompt = ChatPromptTemplate.fromTemplate(
  "Rate sentiment 1-10 (10=positive) for: {text}. Return only the number."
);

const parser = new StringOutputParser();

const summaryChain = summaryPrompt.pipe(model).pipe(parser);
const tagsChain = tagsPrompt.pipe(model).pipe(parser);
const sentimentChain = sentimentPrompt.pipe(model).pipe(parser);

const parallel = RunnableParallel.from({
  summary: summaryChain,
  tags: tagsChain,
  sentiment: sentimentChain,
});

const input = `TypeScript's structural typing lets you compose complex types 
from simple pieces. It catches errors at compile time without runtime overhead.`;

const result = await parallel.invoke({ text: input });

console.log(JSON.stringify(result, null, 2));

Output:

{
  "summary": "TypeScript uses structural typing for compile-time safety with no runtime cost.",
  "tags": "TypeScript, typing, compile-time",
  "sentiment": "8"
}

Each sub-chain receives the same input object. Keys in the parallel map become keys in the output object — fully typed if you add a Zod schema (next step).

Step 6: Validate and coerce output with Zod schemas

LLM output is untrusted. Use StructuredOutputParser with Zod to enforce shape and coerce types. Create src/structured.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";

const schema = z.object({
  summary: z.string().min(10).max(200),
  tags: z.array(z.string()).length(3),
  sentiment: z.number().int().min(1).max(10),
  confidence: z.number().min(0).max(1),
});

const parser = StructuredOutputParser.fromZodSchema(schema);

const prompt = ChatPromptTemplate.fromMessages([
  ["system", `Analyze the text. Return JSON matching this schema:\n{format_instructions}`],
  ["human", "{text}"],
]);

const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.1 });

const chain = prompt.pipe(model).pipe(parser);

const result = await chain.invoke({
  text: "Zod makes runtime validation feel like compile-time safety. It's the bridge between TypeScript types and messy reality.",
  format_instructions: parser.getFormatInstructions(),
});

console.log(JSON.stringify(result, null, 2));

Run it — you get typed, validated output:

{
  "summary": "Zod bridges TypeScript types and runtime validation for safer code.",
  "tags": ["Zod", "TypeScript", "validation"],
  "sentiment": 8,
  "confidence": 0.92
}

If the model returns invalid JSON or wrong types, the parser throws. Wrap in a try/catch or use .withFallbacks() (next step).

Step 7: Add fallbacks for provider failures

Production chains need resilience. Runnable.withFallbacks() accepts an array of alternative runnables — if the primary throws, it tries the next. Create src/fallbacks.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = ChatPromptTemplate.fromTemplate("Translate to French: {text}");
const parser = new StringOutputParser();

const primary = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const fallback = new ChatAnthropic({ model: "claude-3-haiku-20240307", temperature: 0 });

const primaryChain = prompt.pipe(primary).pipe(parser);
const fallbackChain = prompt.pipe(fallback).pipe(parser);

const resilientChain = primaryChain.withFallbacks([fallbackChain]);

const result = await resilientChain.invoke({ text: "Hello, world!" });
console.log(result);

If OpenAI is rate-limited or returns an error, the chain transparently retries with Anthropic. You can chain multiple fallbacks: primary.withFallbacks([secondary, tertiary]).

This is where a gateway like n4n.ai simplifies ops — one endpoint, automatic provider fallback, and unified usage metering without rewriting chain logic.

Step 8: Compose a complete pipeline with branching, validation, and fallbacks

Wire everything together: parallel analysis → structured output → fallback model. Create src/full-pipeline.ts:

import { ChatOpenAI } from "@langchain/openai";
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { RunnableParallel, RunnablePassthrough } from "@langchain/core/runnables";
import { z } from "zod";

const schema = z.object({
  summary: z.string().min(20).max(300),
  keyPoints: z.array(z.string()).min(3).max(5),
  tags: z.array(z.string()).length(4),
  readingLevel: z.enum(["beginner", "intermediate", "advanced"]),
  sentiment: z.number().int().min(1).max(10),
});

const parser = StructuredOutputParser.fromZodSchema(schema);

const analysisPrompt = ChatPromptTemplate.fromMessages([
  ["system", `Analyze the technical article. Return JSON:\n{format_instructions}`],
  ["human", "{article}"],
]);

const openaiModel = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.1 });
const anthropicModel = new ChatAnthropic({ model: "claude-3-haiku-20240307", temperature: 0.1 });

const openaiChain = analysisPrompt.pipe(openaiModel).pipe(parser);
const anthropicChain = analysisPrompt.pipe(anthropicModel).pipe(parser);

const resilientChain = openaiChain.withFallbacks([anthropicChain]);

const article = `
TypeScript 5.5 introduces inferred type predicates, control flow narrowing for 
constant expressions, and isolated declarations for faster builds. The release 
focuses on developer experience: fewer type annotations, better error messages, 
and smoother migration from JavaScript. Teams adopting strict mode will see 
immediate benefits in catch rates for null/undefined bugs.
`;

console.log("Analyzing article...\n");

const result = await resilientChain.invoke({
  article,
  format_instructions: parser.getFormatInstructions(),
});

console.log(JSON.stringify(result, null, 2));

Run it:

npx tsx src/full-pipeline.ts

Sample output:

{
  "summary": "TypeScript 5.5 adds inferred type predicates, constant narrowing, and isolated declarations for faster builds and better DX.",
  "keyPoints": [
    "Inferred type predicates reduce annotation burden",
    "Control flow narrowing for constants catches more bugs",
    "Isolated declarations speed up incremental compilation",
    "Strict mode adoption yields immediate null-safety gains"
  ],
  "tags": ["TypeScript", "5.5", "type-safety", "developer-experience"],
  "readingLevel": "intermediate",
  "sentiment": 8
}

The chain is fully typed end-to-end. Swap resilientChain.stream() for streaming — the parser buffers until valid JSON completes, then emits the parsed object.

Step 9: Add observability with callbacks

LangChain’s callback system hooks into every runnable event. Create src/callbacks.ts to log timing and token usage:

import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { BaseCallbackHandler } from "@langchain/core/callbacks";

class LoggingCallback extends BaseCallbackHandler {
  name = "logging";

  async handleLLMStart(llm: { name?: string }, prompts: string[]) {
    console.log(`[LLM Start] ${llm.name} | prompts: ${prompts.length}`);
  }

  async handleLLMEnd(output: { generations: any[] }) {
    const usage = output.generations[0][0]?.message?.usage_metadata;
    if (usage) {
      console.log(`[LLM End] tokens: ${usage.total_tokens} (in: ${usage.input_tokens}, out: ${usage.output_tokens})`);
    }
  }

  async handleLLMError(error: Error) {
    console.error(`[LLM Error] ${error.message}`);
  }

  async handleChainStart(chain: { name?: string }, inputs: any) {
    console.log(`[Chain Start] ${chain.name} | input keys: ${Object.keys(inputs).join(", ")}`);
  }

  async handleChainEnd(chain: { name?: string }, outputs: any) {
    console.log(`[Chain End] ${chain.name}`);
  }
}

const prompt = ChatPromptTemplate.fromTemplate("Summarize in one sentence: {text}");
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const parser = new StringOutputParser();

const chain = prompt.pipe(model).pipe(parser);

const result = await chain.invoke(
  { text: "LCEL makes composition explicit. Every step is a runnable." },
  { callbacks: [new LoggingCallback()] }
);

console.log("\nResult:", result);

Run it:

npx tsx src/callbacks.ts

Output:

[Chain Start] RunnableSequence | input keys: text
[LLM Start] gpt-4o-mini | prompts: 1
[LLM End] tokens: 42 (in: 28, out: 14)
[Chain End] RunnableSequence

Result: LCEL makes composition explicit with every step as a runnable.

Callbacks work identically for streaming — handleLLMNewToken fires per token. Use this for logging, metrics, or custom streaming UIs.

Step 10: Verify with a test harness

Write a minimal integration test that exercises the full pipeline. Create src/pipeline.test.ts:

import { describe, it, expect } from "vitest";
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";

const schema = z.object({
  summary: z.string().min(10),
  tags: z.array(z.string()).length(3),
});

const parser = StructuredOutputParser.fromZodSchema(schema);
const prompt = ChatPromptTemplate.fromTemplate(
  "Analyze: {text}\n{format_instructions}"
);
const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const chain = prompt.pipe(model).pipe(parser);

describe("LCEL pipeline", () => {
  it("returns valid structured output", async () => {
    const result = await chain.invoke({
      text: "TypeScript adds static types to JavaScript.",
      format_instructions: parser.getFormatInstructions(),
    });

    expect(result).toHaveProperty("summary");
    expect(result.summary.length).toBeGreaterThan(10);
    expect(result.tags).toHaveLength(3);
    expect(result.tags.every(t => typeof t === "string")).toBe(true);
  }, 30000);
});

Install Vitest and run:

npm install -D vitest
npx vitest run src/pipeline.test.ts

The test hits the real API — use a cheaper model or mock the model in CI. The point: your chain is a pure function Input → Promise<Output>, fully testable.

Verification checklist

After completing each step, confirm:

  1. Step 2npx tsx src/runnables.ts prints 12
  2. Step 3npx tsx src/basic-chain.ts prints a coherent paragraph
  3. Step 4npx tsx src/streaming.ts prints tokens incrementally
  4. Step 5npx tsx src/parallel.ts prints JSON with three keys
  5. Step 6npx tsx src/structured.ts prints validated, typed JSON
  6. Step 7npx tsx src/fallbacks.ts prints French translation (simulate failure by using an invalid key on primary)
  7. Step 8npx tsx src/full-pipeline.ts prints full analysis object
  8. Step 9npx tsx src/callbacks.ts logs chain/LLM events
  9. Step 10npx vitest run passes

What to take forward

LCEL chains are ordinary TypeScript values — compose, test, and debug them like any other function. The patterns above scale: add RunnableBranch for conditional logic, RunnableLambda for custom sync/async functions, and Runnable.withConfig() for per-invocation options like runName or tags.

For team workflows, extract chains into separate modules, version prompts alongside code, and treat the gateway as infrastructure — not application logic. The chain definition stays the same whether you call OpenAI directly or route through a gateway that handles fallbacks and metering centrally.

Tagslangchainjslceltypescriptchains

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 langchain.js for node & typescript posts →