The ES|QL query builder provides a fluent, type-safe query API for JavaScript and TypeScript. It solves the common problems of handwritten query strings: errors, poor maintainability, and injection risk. Core capabilities include automatic escaping, IDE autocomplete, and parameter binding. Keywords: ES|QL, TypeScript, Elasticsearch.
Technical specifications are easy to scan
| Parameter | Description | |
|---|---|---|
| Package | @elastic/elasticsearch-esql-dsl |
|
| Language | JavaScript, TypeScript | |
| Target Protocol | Elasticsearch ES | QL API / HTTP |
| Core Capabilities | Fluent construction, type safety, automatic escaping, parameter binding | |
| Typical Dependencies | @elastic/elasticsearch-esql-dsl, @elastic/elasticsearch |
|
| Repository Source | Official Elastic DSL project | |
| Stars | Not provided in the source |
This tool replaces fragile string concatenation with a declarative DSL
Traditional ES|QL queries often rely on template-string concatenation. That approach may look straightforward in the short term, but over time it exposes three problems: fragile quote escaping, hard-to-maintain dynamic conditions, and injection risk from user input.
When a hostname contains special characters such as O'Brien, raw strings can easily produce invalid queries. As query complexity grows, readers must mentally parse the entire template, which significantly increases debugging cost.
const query = `FROM logs-*
| WHERE status_code >= ${minStatus}
AND host.name == ${hostname}
AND @timestamp >= "${startDate}"
| STATS error_count = COUNT(*) BY status_code
| SORT error_count DESC
| LIMIT 10`
This code can work, but escaping issues, injection risk, and maintainability problems all grow as dynamic parameters increase.
A chained API is the better way to build queries
The DSL uses ESQL.from() as the entry point, E() to build field expressions, and f to wrap functions. Each step makes the query intent explicit.
import { ESQL, E, f } from '@elastic/elasticsearch-esql-dsl'
const query = ESQL.from('logs-*')
.where(E('status_code').gte(minStatus)) // Filter by status code
.where(E('host.name').eq(hostname)) // Match the hostname exactly
.where(E('@timestamp').gte(startDate)) // Filter by time range
.stats({ error_count: f.count() }) // Count errors
.by('status_code') // Aggregate by status code
.sort(E('error_count').desc()) // Sort by aggregate result in descending order
.limit(10) // Limit the number of returned rows
This version splits filtering, aggregation, and sorting into independent steps, which improves readability and makes IDE autocomplete and refactoring much easier.
A minimal runnable example clearly shows the usage model
After installation, you can build queries directly and output a standard ES|QL string through render(). This model works especially well for server-side APIs, log analysis scripts, and observability dashboards.
npm install @elastic/elasticsearch-esql-dsl
This command installs the ES|QL DSL package and is the first step in adding query-builder support to a JavaScript or TypeScript project.
import { ESQL, E } from '@elastic/elasticsearch-esql-dsl'
const query = ESQL.from('employees')
.where(E('still_hired').eq(true)) // Keep only active employees
.sort(E('last_name').asc()) // Sort by last name in ascending order
.limit(10) // Return the first 10 rows
console.log(query.render())
This code generates a minimal ES|QL query and prints the final rendered statement.
import { Client } from '@elastic/elasticsearch'
const client = new Client({ node: 'http://localhost:9200' })
const response = await client.esql.query({
query: query.render(), // Render the final ES|QL string
})
This example shows how to submit a DSL-generated query directly to Elasticsearch for execution.
Real log analysis scenarios show the composability of the DSL best
In web error log analysis, developers usually need to add filters, computed columns, aggregations, and time analysis step by step. The main advantage of the DSL is that each extension remains semantically clear.
You can layer everything from error filters to computed columns
import { ESQL, E, f } from '@elastic/elasticsearch-esql-dsl'
const hourlyErrors = ESQL.from('logs-*')
.where(E('status_code').gte(400)) // Keep only error requests
.eval({ hour: f.dateTrunc('@timestamp', '1 hour') }) // Truncate timestamps to the hour
.stats({
error_count: f.count(), // Count errors
avg_response: f.avg('response_time_ms'), // Compute average response time
})
.by('hour') // Aggregate by hour
.sort(E('hour').asc()) // Preserve chronological order in the output
This query combines filtering, time bucketing, and aggregation into one maintainable analytics pipeline.
Immutable query objects make reuse across multiple views safer
Each method call returns a new query object instead of mutating the existing one. That makes it easy to define a shared base filter first and then derive multiple dashboard views without state contamination.
const base = ESQL.from('logs-*')
.where(E('status_code').gte(400)) // Shared error filter
.where(E('@timestamp').gte('2026-01-01T00:00:00Z')) // Shared time range
const byStatus = base
.stats({ count: f.count() }) // Count rows per status code
.by('status_code')
.sort(E('count').desc())
const recent = base
.sort(E('@timestamp').desc()) // Show the most recent errors first
.keep('@timestamp', 'status_code', 'url.path', 'message') // Keep only key fields
.limit(50)
This example demonstrates a common dashboard modeling pattern: define the base query once, then reuse it across multiple views.
Three expression styles cover different levels of query complexity
The DSL does not force developers into a single syntax. Instead, it provides three styles: raw strings, the E() expression builder, and template tags. All of them produce the same ES|QL in the end.
import { and_, esql, E } from '@elastic/elasticsearch-esql-dsl'
const rawWhere = 'status_code >= 400 AND host.name == "web-01"' // Good for quick ad hoc queries
const exprWhere = and_(
E('status_code').gte(400), // Type-safe expression
E('host.name').eq('web-01')
)
const tplWhere = esql`status_code >= ${400} AND host.name == ${'web-01'}` // Safe interpolation
This example shows that the same condition can be expressed in three ways, making it easier to balance flexibility and safety.
Parameter binding is a critical capability for production systems
If query conditions come from user input, the best practice is not to escape values manually but to use parameter binding. In this model, placeholder replacement happens on the Elasticsearch server side, which significantly reduces injection risk.
function searchLogs(userQuery: string) {
const query = ESQL.from('logs-*')
.where(E('message').eq(E('?'))) // Use a placeholder instead of concatenating user input directly
.limit(100)
return client.esql.query({
query: query.render(), // Send the rendered query template
params: [userQuery], // Pass user input safely through a parameter array
})
}
This example highlights the single most important security practice: keep the query template fixed and send user data separately.
Advanced features show that this DSL is more than syntactic sugar
The builder already covers several advanced ES|QL capabilities, including hybrid search, data enrichment, conditional aggregation, and AI/ML integration. That means it fits not only simple filtering use cases but also complex search applications.
const results = ESQL.from('articles')
.fork(
ESQL.branch().where(f.match('title', 'elasticsearch')).limit(50), // Keyword retrieval branch
ESQL.branch().where(f.knn('embedding', 10)).limit(50), // Vector retrieval branch
)
.fuse('RRF') // Fuse the results from both branches
.limit(10)
This example shows how to express a hybrid retrieval workflow that combines keyword search and vector search within the same DSL.
The image metadata reveals the source and ecosystem context
AI Visual Insight: This image is the article’s header graphic. It highlights the official Elastic technical publishing context and typically serves to identify ES|QL query builder content rather than to present a concrete interface or system architecture. Its primary role is content attribution and brand recognition.
The conclusion is that this DSL is an excellent fit for teams that need safe and maintainable queries
If your application needs to build ES|QL dynamically in Node.js services, frontend configuration tools, operations scripts, or observability systems, the value of this DSL is immediate: fewer string-construction errors, better refactorability, and a unified way to express queries.
It is especially well suited to TypeScript teams because type hints, function wrappers, and immutable query objects turn query construction from text concatenation into an evolvable engineering asset.
FAQ provides structured answers to common questions
1. Why not keep using template strings to write ES|QL?
Template strings are fine for one-off experiments, but they are not a good fit for production. They are prone to quote-escaping mistakes, messy condition assembly, and injection risk, especially when user input participates in query generation.
2. Is this DSL only for TypeScript?
No. It supports both JavaScript and TypeScript. TypeScript provides richer type hints and autocomplete, but JavaScript can use the same fluent API directly.
3. When should I prefer parameter binding over automatic escaping?
Whenever input comes from users, external systems, or any untrusted source, you should prefer parameter binding. Automatic escaping addresses syntax safety, while parameter binding further separates data from the query template and is better suited to production environments.
Core Summary: This article explains Elastic’s ES|QL query builder for JavaScript and TypeScript, focusing on its fluent chaining model, type safety, automatic escaping, parameter binding, and advanced query capabilities. It helps developers replace string concatenation and build Elasticsearch queries more safely.