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, returnsPromise<Output>stream(input)— returnsAsyncIterable<Output>for token-by-token outputbatch(inputs)— parallel execution for arrays of inputspipe(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:
- Step 2 —
npx tsx src/runnables.tsprints12 - Step 3 —
npx tsx src/basic-chain.tsprints a coherent paragraph - Step 4 —
npx tsx src/streaming.tsprints tokens incrementally - Step 5 —
npx tsx src/parallel.tsprints JSON with three keys - Step 6 —
npx tsx src/structured.tsprints validated, typed JSON - Step 7 —
npx tsx src/fallbacks.tsprints French translation (simulate failure by using an invalid key on primary) - Step 8 —
npx tsx src/full-pipeline.tsprints full analysis object - Step 9 —
npx tsx src/callbacks.tslogs chain/LLM events - Step 10 —
npx vitest runpasses
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.