n4nAI

TypeScript types for LangChain.js runnables explained

A practical guide to LangChain.js runnable types in TypeScript — what they are, how input/output generics work, and how to compose typed chains without fighting the compiler.

n4n Team4 min read886 words

Audio narration

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

LangChain.js runnable types are the TypeScript generic signatures that describe what a runnable accepts as input, emits as output, and expects for configuration. The core Runnable<Input, Output, Config> interface makes these three type parameters explicit, letting the compiler verify chain composition before runtime. Understanding these generics is the difference between catching type mismatches at compile time and debugging cryptic errors in production.

The runnable interface in plain terms

Every LangChain.js component that implements the runnable protocol — prompts, models, output parsers, retrievers, and custom functions — conforms to Runnable<Input, Output, Config>. The three type parameters map directly to the three arguments of the core methods: invoke(input, config?), stream(input, config?), and batch(inputs, config?).

interface Runnable<Input, Output, Config extends RunnableConfig = RunnableConfig> {
  invoke(input: Input, config?: Config): Promise<Output>;
  stream(input: Input, config?: Config): AsyncIterable<Output>;
  batch(inputs: Input[], config?: Config): Promise<Output[]>;
  // ... other methods
}

Input is what you pass in. Output is what you get back. Config carries callbacks, metadata, tags, and runtime options like maxConcurrency. The default RunnableConfig covers most cases, but you can extend it when your component needs custom configuration.

Why the generics matter for composition

LangChain.js chains runnables together with the pipe operator (|). The type system enforces that the output of the left operand matches the input of the right operand. If you pipe a Runnable<string, string[]> into a Runnable<string[], number>, TypeScript accepts it. If you reverse them, you get a compile error.

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

const prompt = PromptTemplate.fromTemplate("Summarize: {text}");
// PromptTemplate: Runnable<{ text: string }, PromptValue>

const model = new ChatOpenAI({ model: "gpt-4o-mini" });
// ChatOpenAI: Runnable<PromptValue | string | BaseMessage[], AIMessage>

const parser = new StringOutputParser();
// StringOutputParser: Runnable<AIMessage | BaseMessage, string>

const chain = prompt.pipe(model).pipe(parser);
// chain: Runnable<{ text: string }, string>

The inferred type of chain is Runnable<{ text: string }, string>. You cannot accidentally pass a number where { text: string } is expected, and you cannot treat the result as anything but a string.

Input and output types in practice

Most built-in runnables expose narrow, specific types. A prompt template expects an object whose keys match its input variables. A chat model accepts a prompt value, string, or message array and returns an AIMessage. An output parser transforms that message into a string, JSON, or a structured object.

import { JsonOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";

const schema = z.object({
  title: z.string(),
  bullets: z.array(z.string()),
});

const parser = new JsonOutputParser({ schema });
// parser: Runnable<AIMessage, { title: string; bullets: string[] }>

When you pipe this parser after a model, the chain output becomes the parsed object. The compiler knows the shape, so autocomplete works on result.title and result.bullets.

Config typing and custom configuration

The third generic, Config, defaults to RunnableConfig. You rarely need to touch it unless you build a custom runnable that requires extra configuration fields. For example, a retriever that accepts a runtime namespace parameter:

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

interface RetrieverConfig extends RunnableConfig {
  namespace?: string;
}

class CustomRetriever extends Runnable<string, Document[], RetrieverConfig> {
  async invoke(query: string, config?: RetrieverConfig) {
    const ns = config?.configurable?.namespace ?? "default";
    // fetch documents from ns
    return [];
  }
}

Downstream consumers can pass { configurable: { namespace: "tenant-42" } } and TypeScript validates the key exists.

Streaming types and async iterables

stream() returns an AsyncIterable<Output>. For token-by-token streaming, Output is often a string chunk. For object streaming, it might be partial objects. The type system does not automatically infer chunk shapes from the final output — you must know what the specific runnable emits.

for await (const chunk of chain.stream({ text: "long document..." })) {
  // chunk: string (because StringOutputParser streams string deltas)
  process.stdout.write(chunk);
}

If you swap StringOutputParser for a JsonOutputParser, the stream type changes to partial JSON objects. Check the component documentation or hover the stream method in your editor to see the exact chunk type.

Batch and concurrency

batch(inputs, config?) takes an array of inputs and returns a promise of output arrays. The config can include maxConcurrency to limit parallelism. Types remain consistent: Input[] in, Output[] out.

const results = await chain.batch([
  { text: "doc 1" },
  { text: "doc 2" },
  { text: "doc 3" },
], { maxConcurrency: 2 });
// results: string[]

Common misconceptions

Misconception: RunnableSequence is a distinct type

RunnableSequence is an internal class created by the pipe operator. You rarely reference it directly. The public type is always Runnable<Input, Output, Config>. Treat the pipe result as a black-box runnable with inferred generics.

Misconception: You must annotate every chain

Type inference works well for linear chains. Annotate only when you need to expose a public API boundary, enforce a specific interface, or help the compiler with complex branching.

// Helpful annotation at a module boundary
export const summarizationChain: Runnable<{ text: string }, Summary> =
  prompt.pipe(model).pipe(parser);

Misconception: Config propagates automatically through pipes

Config does not merge automatically. The composed runnable’s Config defaults to RunnableConfig. If you need a custom config field at the top level, you must cast or wrap:

const chain = prompt.pipe(model).pipe(parser) as Runnable<
  { text: string },
  string,
  RetrieverConfig
>;
// Now chain.invoke(input, { configurable: { namespace: "x" } }) type-checks

Misconception: All runnables support streaming

Not every runnable implements stream meaningfully. Some fall back to buffering the full result and yielding it once. Check the component’s documentation. If you need true streaming, verify the runnable advertises streaming support.

Debugging type errors

When the compiler complains about a pipe, read the error from left to right. It will tell you which operand’s output fails to match the next operand’s input. Common causes:

  • Forgetting that a prompt template expects an object, not a raw string
  • Piping a string-output parser into a component that expects a message
  • Mixing BaseMessage arrays with PromptValue where a single type is required

Hover each segment in your IDE to see its inferred Runnable<In, Out>. The mismatch usually jumps out.

Practical pattern: typed wrapper functions

If you expose chains to other teams or packages, wrap them in a function that returns a typed runnable. This hides implementation details and locks the contract.

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

export interface SummarizeInput {
  text: string;
  maxLength?: number;
}

export interface Summary {
  tldr: string;
  keyPoints: string[];
}

export function createSummarizationChain(): Runnable<SummarizeInput, Summary> {
  const prompt = PromptTemplate.fromTemplate(/* ... */);
  const model = new ChatOpenAI({ model: "gpt-4o-mini" });
  const parser = new JsonOutputParser({ schema: summarySchema });
  return prompt.pipe(model).pipe(parser);
}

Consumers import createSummarizationChain and get full type safety without knowing the internal pipe structure.

When to use RunnableLambda for custom logic

RunnableLambda lets you wrap any async function as a runnable with explicit types. Use it for preprocessing, postprocessing, or side effects that don’t fit existing components.

import { RunnableLambda } from "@langchain/core/runnables";

const addTimestamp = new RunnableLambda({
  func: async (input: { text: string }) => ({
    ...input,
    timestamp: new Date().toISOString(),
  }),
  // Optional: name appears in traces
  name: "AddTimestamp",
});
// addTimestamp: Runnable<{ text: string }, { text: string; timestamp: string }>

The lambda infers types from the function signature. You can also pass explicit generics if inference falls short.

Interoperability with non-runnable code

You can always escape the runnable system with .invoke() and handle the raw promise. The boundary is explicit:

const result: string = await chain.invoke({ text: "input" });
// result is a plain string, no runnable machinery involved

This is useful when integrating with legacy code, web frameworks, or any context that doesn’t understand the runnable protocol.

Summary

LangChain.js runnable types center on Runnable<Input, Output, Config>. The pipe operator composes them with full type inference, catching mismatches at compile time. Most day-to-day work requires only the first two generics; the third appears when you build custom components with specialized configuration. Annotate at API boundaries, trust inference inside chains, and hover types in your editor when the compiler disagrees with your mental model. The type system is strict because runnable composition is where runtime surprises are most expensive.

Tagslangchainjstypescriptrunnablestypes

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 →