DEV Community

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

Elasticsearch silent failures

Here's a query that can compile and run, but return an empty result set:

{
  "query": {
    "match": {
      "category": "electronics"
    }
  }
}

What happened? category data was indexed as an un-analyzed keyword field. match analyzes its input, so the query doesn't line up with the data stored in the index. Elasticsearch quietly accepts the request and returns zero hits.

Another silent failure:

{
  "query": {
    "term": {
      "catgory": "electronics"
    }
  }
}

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

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 what elasticlink does.

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 })
});

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();

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()

Typos are also caught in your IDE:

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

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
}

Spread it straight into the official client:

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

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();

Field references are validated against your mapping (with auto-complete), 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 around 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[];
// }

"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.

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

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();

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();

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

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

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

If you've ever wished there was 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.

Comments

No comments yet. Start the discussion.