If you’re planning a langchain.js typescript getting started project, skip the boilerplate confusion by setting up a strict Node toolchain first. This guide walks through a pragmatic path from empty directory to a typed, streaming LLM chain, with notes on where the abstraction helps and where it gets in the way.
Toolchain setup
Initialize a Node project with ESM modules. LangChain.js ships ESM-first; mixing CommonJS will cost you a day of ERR_REQUIRE debugging. Use Node 18 or 20—the built-in fetch and ReadableStream avoid extra polyfills.
mkdir lc-ts-demo && cd lc-ts-demo
npm init -y
npm pkg set type="module"
Create a tsconfig.json that targets Node 18+ and enables strict. The NodeNext resolution mode matches how Node actually loads ESM.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
A clean langchain.js typescript getting started setup keeps dist out of source control and runs via tsx during development.
npm install -D tsx
Install LangChain packages
Install only what you need. The monolithic langchain package pulls in everything; prefer scoped @langchain/core and a provider package. This keeps your node_modules lean and your import graph auditable.
npm install @langchain/core @langchain/openai
npm install -D typescript tsx
@langchain/core gives you Runnable, PromptTemplate, and BaseLanguageModel. @langchain/openai provides ChatOpenAI. Keep versions aligned—core and provider packages use peer dependencies with tight ranges, and a mismatch surfaces as cryptic Cannot read properties of undefined at runtime.
Define a typed prompt and model
Build a prompt template with explicit input variables. fromTemplate infers the variable names from the string, but that inference is loose.
import { PromptTemplate } from "@langchain/core/prompts";
interface JokeInput { topic: string }
const jokePrompt = PromptTemplate.fromTemplate<JokeInput>(
`Tell a short joke about {topic}. Output only the joke.`
);
Now the model. Point it at an OpenAI-compatible endpoint. Set temperature and model explicitly; defaults drift between releases.
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
model: "gpt-4o-mini",
temperature: 0.7,
apiKey: process.env.OPENAI_API_KEY,
});
If you need access to many models without juggling keys, an OpenAI-compatible gateway like n4n.ai exposes one endpoint for 240+ models with automatic fallback on rate limits and per-token metering. Point ChatOpenAI at its baseURL and keep the rest of your code identical.
const model = new ChatOpenAI({
model: "anthropic/claude-3-haiku",
temperature: 0.7,
apiKey: process.env.N4N_API_KEY,
configuration: {
baseURL: "https://api.n4n.ai/v1",
},
});
Compose a runnable chain
LangChain’s real product is the Runnable interface. RunnableSequence types the intermediate steps and lets you swap components without rewriting call sites.
import { RunnableSequence } from "@langchain/core/runnables";
const chain = RunnableSequence.from([jokePrompt, model]);
async function run(topic: string) {
const result = await chain.invoke({ topic });
console.log(result.content);
}
run("typescript");
The invoke method returns an AIMessage. Access .content for the string. Don’t assume .text exists—that’s a v0.1-era trap.
Why not LLMChain?
LLMChain is legacy. It hides the runnable interface and breaks composition with retrievers or parsers. Use pipe for clarity:
const chain = jokePrompt.pipe(model);
This is equivalent to the sequence and reads left-to-right.
Streaming responses
Users expect tokens to appear incrementally. LangChain supports stream on runnables backed by streaming models.
const stream = await chain.stream({ topic: "docker" });
for await (const chunk of stream) {
const text = typeof chunk.content === "string" ? chunk.content : "";
process.stdout.write(text);
}
Pitfall: chunk.content is a string for chat models but can be an array for multimodal inputs. Cast carefully or guard with typeof.
To cancel, wrap the loop in an AbortController and pass signal to the model constructor. LangChain propagates it to the underlying fetch.
const controller = new AbortController();
const model = new ChatOpenAI({ model: "gpt-4o-mini", signal: controller.signal });
Common pitfalls and tradeoffs
Hidden any in templates
As shown above, generic parameter on fromTemplate is your friend. Without it, the input type is Record<string, any> and a typo in {topc} compiles fine.
ESM and test runners
Jest doesn’t love ESM. Use vitest for tests with environment: "node". tsx handles scripts. Don’t fight the toolchain—switch.
Over-abstraction
LangChain adds a layer between you and the API. For a single call, fetch to the endpoint is 10 lines. Use LangChain when you compose multiple steps: prompt → model → parser → retriever. Otherwise it’s weight you ship and debug.
Token counting
ChatOpenAI doesn’t surface token usage on invoke unless you read response_metadata. For metering, inspect it:
const usage = (result as any).response_metadata?.usage;
If you need precise per-token cost, a gateway that returns usage in response metadata or headers is simpler than parsing after the fact.
Version drift
@langchain/core and provider packages must match peer ranges. Lock them together in your package.json and review updates in a branch. A minor bump in core can change AIMessage shape.
Provider configuration and fallback
Production needs resilience. Providers throttle. If you call OpenAI directly, you write retry logic. Set maxRetries on the model, but understand it’s exponential backoff only.
const model = new ChatOpenAI({ maxRetries: 3 });
For multi-provider routing, honor client directives. Some gateways let you pass extra_body to select a provider or enable cache control. LangChain forwards unknown keys if you use modelKwargs.
const model = new ChatOpenAI({
model: "gpt-4o",
modelKwargs: { provider: "azure", cache_control: { type: "ephemeral" } },
});
This only works if the endpoint understands those hints. Test it against the actual response, not the TypeScript type.
Type safety and testing
Write a thin wrapper around your chain to enforce input/output types at the boundary.
async function tellJoke(topic: string): Promise<string> {
const msg = await chain.invoke({ topic });
return typeof msg.content === "string" ? msg.content : "";
}
Test with vitest and a mock model. @langchain/core exports FakeLLM for this.
import { FakeLLM } from "@langchain/core/utils/testing";
const fake = new FakeLLM({ response: "Why did TS cross the road? To type it." });
const testChain = jokePrompt.pipe(fake);
Swap model for fake via dependency injection, not environment vars. Your langchain.js typescript getting started code stays honest: the types catch a missing topic long before runtime.
Project structure
Keep it flat until it isn’t. A useful layout:
src/
chains/joke.ts
models/index.ts
prompts/joke.ts
index.ts
test/
joke.test.ts
Export the configured model from models/index.ts so tests and app code share one construction site. Avoid circular imports by keeping prompts free of model references.
Handling errors
Model calls fail. Wrap invoke in try/catch and inspect error.response?.status. LangChain rethrows provider errors; they aren’t always LangChainError. Log the response_metadata but never log the API key.
try {
await chain.invoke({ topic });
} catch (err) {
if (err instanceof Error && "response" in err) {
console.error("Provider failed:", (err as any).response?.status);
}
}
Where to go next
Add an output parser for structured data. @langchain/core/output_parsers has JsonOutputParser. Compose with RunnableParallel to call multiple models and merge results. Read the LangChain.js docs on Runnable—that interface is the stable contract.
Keep your langchain.js typescript getting started codebase small. Every Runnable you add is a place for latency to hide. Measure end-to-end before optimizing prompts. A strict TS config and scoped installs will save more time than any chain trick.