DEV Community

Cover image for Type-safe Elasticsearch queries in TypeScript (and JavaScript) with elasticlink
John Rodger
John Rodger

Posted on • Originally published at github.com

Type-safe Elasticsearch queries in TypeScript (and JavaScript) with elasticlink

Elasticsearch silent failures

The following query can compile and run, but unexpectedly return an empty result set:

{ "query": { "match": { "category": "electronics" } } }
Enter fullscreen mode Exit fullscreen mode

How can that happen?

As every engineer working with Elasticsearch eventually learns: field mappings and queries must match; otherwise, queries can fail silently. In the above example, if category data was indexed as an un-analyzed keyword field but then queried with match, which analyzes its input, the types will not match. Elasticsearch quietly accepts the request but returns zero hits.

Another silent failure:

{ "query": { "term": { "catgory": "electronics" } } }
Enter fullscreen mode Exit fullscreen mode

A typo in the field name: also valid DSL, so no errors, but no hits.

The known gap is that in TS/JS, Elasticsearch's query DSL is an untyped JSON blob, and your editor has no idea how your fields are mapped or which query clauses make semantic sense.

That's the gap elasticlink fills.

What is elasticlink?

elasticlink is a type-safe, mapping-aware query builder for Elasticsearch. You describe your index mapping once, and every query method is constrained by it: match() only accepts text fields, term() only exact-value fields, knn() only vector fields, and field names autocomplete. Mistakes are red squiggles in your editor, not production errors.

It's TypeScript-first but works great in plain JavaScript too. It's a builder, not a client wrapper, so .build() returns plain Elasticsearch DSL with no runtime overhead, which you hand straight to the official @elastic/elasticsearch client.

Let's see it:

Step 1: define your mapping once

import { mappings, text, keyword, float, denseVector } from 'elasticlink';

const productMappings = mappings({
  name: text(),
  description: text(),
  price: float(),
  category: keyword(),
  embedding: denseVector({ dims: 384 })
});
Enter fullscreen mode Exit fullscreen mode

This is the single source of truth. Everything below is derived from it: document types and query safety.

Step 2: build a query, and watch the compiler work

Here's a correct query: match on the product name, filter by category and price. Field names autocomplete, and each clause is checked against the field's type:

import { queryBuilder } from 'elasticlink';

const query = queryBuilder(productMappings)
  .bool()
  .must((q) => q.match('name', 'wireless headphones')) // ✅ 'name' is a text field
  .filter((q) => q.term('category', 'electronics')) // ✅ 'category' is a keyword field
  .filter((q) => q.range('price', { gte: 50, lte: 300 })) // ✅ 'price' is numeric
  .size(20)
  .build();
Enter fullscreen mode Exit fullscreen mode

Now the buggy version, except this time it never reaches Elasticsearch because it doesn't compile:

queryBuilder(productMappings).match('category', 'electronics');
//                                  ^^^^^^^^^^
// ❌ 'category' is a keyword field — use term(), not match()
Enter fullscreen mode Exit fullscreen mode

Typos are also caught in your IDE:

queryBuilder(productMappings).term('catgory', 'electronics');
//                                 ^^^^^^^^^
// ❌ Argument of type '"catgory"' is not assignable — did you mean 'category'?
Enter fullscreen mode Exit fullscreen mode

Step 3: use the generated Elasticsearch DSL

.build() returns a plain object in the DSL Elasticsearch expects:

{
  "query": {
    "bool": {
      "must": [{ "match": { "name": "wireless headphones" } }],
      "filter": [
        { "term": { "category": "electronics" } },
        { "range": { "price": { "gte": 50, "lte": 300 } } }
      ]
    }
  },
  "size": 20
}
Enter fullscreen mode Exit fullscreen mode

Spread it straight into the official client:

const response = await client.search({ index: 'products', ...query });
Enter fullscreen mode Exit fullscreen mode

Since you already use @elastic/elasticsearch, elasticlink is a compile-time-only dependency.

Works on real-life queries

The safety holds all the way through real requests: boolean logic, filters, and aggregations in one chain:

const facetedSearch = queryBuilder(productMappings)
  .bool()
  .must((q) => q.match('name', 'gaming laptop', { operator: 'and' }))
  .filter((q) => q.term('category', 'electronics'))
  .filter((q) => q.range('price', { gte: 800, lte: 2000 }))
  .aggs((agg) =>
    agg
      .terms('by_category', 'category', { size: 10 })
      .avg('avg_price', 'price')
  )
  .size(20)
  .build();
Enter fullscreen mode Exit fullscreen mode

Field references are validated against your mapping (with autocomplete), including inside the aggregations. See the repo for extended examples.

Bonus: derive your document type for free

Because your mapping is a real value, you can infer a TypeScript type from it to use throughout your application. No second source of truth to keep in sync:

import { type Infer } from 'elasticlink';

type Product = Infer<typeof productMappings>;
// => {
//   name: string;
//   description: string;
//   price: number;
//   category: string;
//   embedding: number[];
// }
Enter fullscreen mode Exit fullscreen mode

"But I'm writing plain JavaScript"

Add // @ts-check and you still get the field-name autocomplete and the type constraints in vanilla JS. elasticlink ships both ESM and CommonJS builds, so require() and import both work out of the box:

// @ts-check
const { mappings, text, keyword, float, queryBuilder } = require('elasticlink');

const productSchema = mappings({
  name: text(),
  price: float(),
  category: keyword()
});

const query = queryBuilder(productSchema)
  .bool()
  .must((q) => q.match('name', 'laptop')) // 'name' is text — allowed
  .filter((q) => q.range('price', { gte: 500 })) // 'price' is numeric — allowed
  .filter((q) => q.term('category', 'electronics')) // 'category' is keyword — allowed
  .build();

// Same as in TypeScript: .match('category', …) is flagged right in the editor.
Enter fullscreen mode Exit fullscreen mode

Need the document type in JavaScript? There's a tiny inferType() helper for JSDoc:

const { inferType } = require('elasticlink');

const _Product = inferType(productSchema); // type-only; returns undefined at runtime

/** @type {typeof _Product} */
const doc = { name: 'Laptop', price: 999, category: 'electronics' };
// ^ full autocomplete on every property
Enter fullscreen mode Exit fullscreen mode

How does it stay correct across Elasticsearch versions?

elasticlink hand-rolls the parts that add value (which methods exist, and which fields they accept given your mapping) and passes the option bags (all the knobs on match, range, knn, index settings, analyzers…) straight through from @elastic/elasticsearch's own types.

That means when Elasticsearch adds an option in a minor release, you'll generally get it for free, with correct types and no drift. It also means there's nothing extra at runtime: the library evaporates at .build().

Other goodies

The same mapping-aware safety runs through the rest of the surface, too. A few highlights worth a look:

kNN / vector search. Semantic and hybrid search are first-class, and .knn() only accepts your dense_vector fields:

const results = queryBuilder(productMappings)
  .knn('embedding', queryVector, {
    k: 10,
    num_candidates: 100,
    filter: { term: { category: 'electronics' } } // hybrid: vectors + a keyword filter
  })
  .size(10)
  .build();
Enter fullscreen mode Exit fullscreen mode

Conditional building with .when(). Assemble queries from runtime state without breaking the chain. When the condition is falsy, the branch is skipped:

const search = queryBuilder(productMappings)
  .bool()
  .when(term, (q) => q.must((c) => c.match('name', term!)))
  .when(category, (q) => q.filter((c) => c.term('category', category!)))
  .when(maxPrice != null, (q) => q.filter((c) => c.range('price', { lte: maxPrice! })))
  .build();
Enter fullscreen mode Exit fullscreen mode

Index management and optimization presets. Ready-made settings for common lifecycle stages: spread them into indexBuilder().settings(...) or send them straight to the _settings API:

import { productionSearchSettings, fastIngestSettings, indexSortSettings } from 'elasticlink';

fastIngestSettings(); // disable refresh + replicas + async translog for a big bulk load
productionSearchSettings(); // balanced defaults to switch back to afterward
indexSortSettings({ price: 'desc' }); // index-time sort for compression + early termination
Enter fullscreen mode Exit fullscreen mode

And more to mix and match: typed aggregations, suggesters (suggest()), multi-search (msearch()), bulk operations (bulk()), declarative index management (indexBuilder() with custom analyzers), geo and span queries, and 40+ field helpers. It's a menu, not a framework: pull in the pieces you want, ignore the rest, and everything still emits plain Elasticsearch DSL.

See the README for more info.

Try it

npm install elasticlink @elastic/elasticsearch
Enter fullscreen mode Exit fullscreen mode

Requirements: Node.js 20+ and Elasticsearch 9.x.

If you've ever wished there were a type-safe fluent builder for Elasticsearch in TS/JS, or shipped a query that quietly returned nothing, give it a try.

A ⭐ on the repo is appreciated. Issues and ideas welcome.

Top comments (0)