npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vahor/typed-es

v0.4.2

Published

Infer type-safe Elasticsearch search, msearch, and aggregation responses from your query.

Downloads

1,149

Readme

Typed ES — type-safe Elasticsearch responses for TypeScript

Code quality npm version npm downloads license

Type-safe Elasticsearch responses for TypeScript, inferred from your actual search query.

@vahor/typed-es augments the official @elastic/elasticsearch client types so _source, fields, hits.total, aggregations, msearch, asyncSearch, and inner_hits stay synchronized with the query you wrote.

Without it, keeping correct manual response types means duplicating your query shape in TypeScript. Every time _source, fields, track_total_hits, or aggs changes, the matching client.search<TDocument, TAggregations>() types need to change too. That is tedious, easy to forget, and often ends in as any.

If you have ever written or maintained client.search<TDocument, TAggregations>(), result.aggregations as any, or hit._source as MyType, this library is for you.

  • Works with the official Elasticsearch JavaScript client.
  • No query builder or runtime DSL to learn: keep writing normal Elasticsearch requests.
  • typedEs is a pass-through helper for type inference and does not change the Elasticsearch request.
  • Can be used as a type-only/dev dependency if you only need the exported types.
  • Tested with Elasticsearch 8 and 9.

Install

npm install @vahor/typed-es

# or with -D if you don't intend to use the typedEs wrapper. 
npm install -D @vahor/typed-es

@vahor/typed-es can be a type-only dependency. Install it as a dev dependency if you only use its exported types and do not call typedEs at runtime.

Common problems solved

  • Type-safe Elasticsearch aggregations in TypeScript.
  • Inferred _source response types from selected fields and wildcards.
  • Autocomplete for valid index names and requested fields.
  • Safer msearch response typing per request.
  • Fewer duplicated response interfaces that drift from the query over time.
  • Fewer as any, non-null assertions, and hand-written aggregation result types.

Bucket Aggregations

| Aggregation | Status | Documentation | |-------------|--------|---------------| | Adjacency Matrix | ✅ | docs | | Auto Date Histogram | ✅ | docs | | Categorize Text | ✅ | docs | | Children | ✅ | docs | | Composite | ✅ | docs | | Date Histogram | ✅ | docs | | Date Range | ✅ | docs | | Diversified Sampler | ✅ | docs | | Filter | ✅ | docs | | Filters | ✅ | docs | | Frequent Item Sets | ✅ | docs | | Geohash Grid | ✅ | docs | | Geohex Grid | ✅ | docs | | Geotile Grid | ✅ | docs | | Global | ✅ | docs | | Histogram | ✅ | docs | | IP Prefix | ✅ | docs | | IP Range | ✅ | docs | | Missing | ✅ | docs | | Multi Terms | ✅ | docs | | Parent | ✅ | docs | | Nested | ✅ | docs | | Random Sampler | ✅ | docs | | Range | ✅ | docs | | Rare Terms | ✅ | docs | | Reverse Nested | ✅ | docs | | Sampler | ✅ | docs | | Significant Terms | ✅ | docs | | Significant Text | ✅ | docs | | Terms | ✅ | docs | | Time Series | ✅ | docs | | Variable Width Histogram | ✅ | docs |

Metrics Aggregations

| Aggregation | Status | Documentation | |-------------|--------|---------------| | Avg | ✅ | docs | | Boxplot | ✅ | docs | | Cardinality | ✅ | docs | | Cartesian Bounds | ✅ | docs | | Cartesian Centroid | ✅ | docs | | Extended Stats | ✅ | docs | | Geo Bounds | ✅ | docs | | Geo Centroid | ✅ | docs | | Geo Line | ✅ | docs | | Matrix Stats | ✅ | docs | | Max | ✅ | docs | | Median Absolute Deviation | ✅ | docs | | Min | ✅ | docs | | Percentile Ranks | ✅ | docs | | Percentiles | ✅ | docs | | Rate | ✅ | docs | | Scripted Metric | ✅ | docs | | Stats | ✅ | docs | | String Stats | ✅ | docs | | Sum | ✅ | docs | | T-Test | ✅ | docs | | Top Hits | ✅ | docs | | Top Metrics | ✅ | docs | | Value Count | ✅ | docs | | Weighted Avg | ✅ | docs |

Pipeline Aggregations

| Aggregation | Status | Documentation | |-------------|--------|---------------| | Average Bucket | ✅ | docs | | Bucket Script | ✅ | docs | | Bucket Count K-S Test | ✅ | docs | | Bucket Correlation | ✅ | docs | | Bucket Selector | ✅ | docs | | Bucket Sort | ✅ | docs | | Change Point | ✅ | docs | | Cumulative Cardinality | ✅ | docs | | Cumulative Sum | ✅ | docs | | Derivative | ✅ | docs | | Extended Stats Bucket | ✅ | docs | | Inference | ✅ | docs | | Max Bucket | ✅ | docs | | Min Bucket | ✅ | docs | | Moving Function | ✅ | docs | | Moving Percentiles | ✅ | docs | | Normalize | ✅ | docs | | Percentiles Bucket | ✅ | docs | | Serial Differencing | ✅ | docs | | Stats Bucket | ✅ | docs | | Sum Bucket | ✅ | docs |

What gets typed

  • Search hits: _source is inferred from the requested _source fields.
  • Elasticsearch aggregations: aggregation output types are inferred from the aggs/aggregations object.
  • Requested fields: fields and docvalue_fields appear on the response with the right field names.
  • Script fields: script_fields appear on the response under fields with unknown values.
  • Total hits: hits.total follows track_total_hits and rest_total_hits_as_int.
  • Wildcard _source selections: _source: ["*_at"] narrows the response to matching fields like created_at.
  • Multi-search: msearch responses preserve the type of each request pair.
  • Async search: asyncSearch.get<typeof query>() can recover the original response type.
  • Child inner hits: has_child + inner_hits is keyed by child type or inner_hits.name.

Quick example

import { Client } from "@elastic/elasticsearch";
import { type TypedClient, typedEs } from "@vahor/typed-es";

type MyIndexes = {
	"my-index": {
		id: number;
		name: string;
		created_at: string;
	};
};

const client = new Client({ /* config */ }) as unknown as TypedClient<MyIndexes>;

const query = typedEs(client, {
	index: "my-index",
	_source: ["id", "na*"],
	fields: [{ field: "created_at", format: "yyyy-MM-dd" }],
	track_total_hits: true,
	rest_total_hits_as_int: true,
	aggs: {
		name_counts: { terms: { field: "name" } },
	},
});

const result = await client.search(query);

const total = result.hits.total;
//    ^? number

const firstHit = result.hits.hits[0];
//    ^? { _source: { id: number; name: string }, fields: { created_at: string[] } }

const buckets = result.aggregations.name_counts.buckets;
//    ^? Array<{ key: string | number; doc_count: number }>

See examples/search-and-aggregations.ts and the tests directory for more inference examples.

Before / after

The official Elasticsearch client is flexible, but TypeScript often loses the exact response shape. You can write correct manual types, but then every query change also becomes a type-maintenance task. @vahor/typed-es keeps the query and response connected.

Without providing any types

const result = await client.search(query);
const total = result.hits.total; // number | estypes.SearchTotalHits | undefined
const firstHit = result.hits.hits[0]._source; // unknown
const aggregationBuckets = result.aggregations!.name_counts.buckets; // any, ts error: Object is possibly 'undefined'.

With manual type definitions

This works, but it duplicates the query shape. If you add a field to _source, rename name_counts, or change the aggregation type, you must remember to update these types too.

const result = await client.search<
  { id: number; created_at: string; },
  {
    name_counts: {
      buckets: Array<{ key: string; doc_count: number }>;
    };
  }
>(query);

const total = result.hits.total; // number | estypes.SearchTotalHits | undefined
const firstHit = result.hits.hits[0]; // { _source: { id: number; created_at: string; } | undefined, fields: Record<string, unknown> }
const aggregationBuckets = result.aggregations!.name_counts.buckets; // Array<{ key: string; doc_count: number; }>

With @vahor/typed-es

// Automatic type inference - no manual definitions needed
const result = await client.search(query);
const total = result.hits.total; // number
const firstHit = result.hits.hits[0]._source; // { id: number; created_at: string }
const aggregationBuckets = result.aggregations.name_counts.buckets; // Array<{ key: string | number; doc_count: number }> 

Usage

Step 1: Define your index types

type CustomIndexes = {
    "first-index": {
        score: number;
        entity_id: string;
        date: string;
    },
    "second-index": {
        "some-field": string;
    }
}

ex:

{
    "mappings": {
        "properties": {
            "location": {
                "type": "point"
            },
            "date": {
                "type": "date"
            }
        }
    }
}

would give:

type CustomIndexes = {
	"first-index": {
		location: string;
		date: string;
	};
};

Step 2: Create a client

import { Client } from "@elastic/elasticsearch";
import type { TypedClient } from "@vahor/typed-es";

const client = new Client({
	// Elasticsearch client config
}) as unknown as TypedClient<CustomIndexes>;

Step 3: Use the typedEs function

import { typedEs } from "@vahor/typed-es";

const query = typedEs(client, {
    index: "first-index",
    _source: ["score", "entity_id", "*ate"],
});

const queryWithAggs = typedEs(client, {
    index: "first-index",
    _source: ["score", "entity_id", "*ate"],
    aggs: {
        some_agg: {
            terms: {
                field: "entity_id",
            },
        },
    },
});

typedEs is a simple wrapper that adds type safety to index and autocompletes _source. Check its definition in typed-es.ts; you can reuse the same pattern to add default values to your queries.

Note: when _source is missing, the output contains every field.

Step 4: Enjoy an easy type-safe output

// Use the Elasticsearch client as usual
const output = await client.search(query);

// And without having to add .search<Sources, Aggs>(query) everywhere, you now have access to the correct types
const hits = output.hits.hits;
for (const hit of hits) {
    // Here hit is typed as { _source: { score: number; entity_id: string, date: string } }
    const score = hit._source.score; // typed as number
    const entity_id = hit._source.entity_id; // typed as string
    const invalid = hit._source.invalid; // error: Property 'invalid' does not exist on type '{ score: number; entity_id: string; }'
}


const outputWithAggs = await client.search(queryWithAggs);
const aggs = outputWithAggs.aggregations;
const someAgg = aggs.some_agg;
const someAggTerms = someAgg.buckets;
for (const bucket of someAggTerms) {
    // Here bucket is typed as { key: string | number; doc_count: number }
    const key = bucket.key; // typed as string | number
    const doc_count = bucket.doc_count; // typed as number
}

With this you also get type-errors when you try to access a field that doesn't exist in the index. Or an invalid index. And with that, also autocompletion for these fields.

const invalidIndex = typedEs(client, {
    index: "invalid-index", // Here we get a: Type '"invalid-index"' is not assignable to type '"first-index" | "second-index"'. 
    _source: ["score", "entity_id"],
});

See more examples in the test files.

Usage with asyncSearch

The asyncSearch API has some complexity for us. submit can infer the response type directly from the submitted query, but get does not include the original query type information by default. To work around that, pass the original query type to get.

const query = typedEs(...);

const submitted = await client.asyncSearch.submit(query);
const submittedData = submitted.response; // Same type as if you used client.search(query)

const result = await client.asyncSearch.get<typeof query>({ id: "abc" });
const data = result.response; // Same type as if you used client.search(query)

// If you don't have a query variable, you can pass the query type explicitly.
const result = await client.asyncSearch.get<{ query: ... }>({ id: "abc" });

Usage with msearch

Run multiple searches in a single request with msearch. The top-level index is used as the default. You can override the index per-search using the header that precedes each body.

// Assuming `client` is a TypedClient<YourIndexes>
const result = await client.msearch({
	index: "first-index",
	searches: [
		// 1) Uses top-level `index`: "first-index"
		{},
		{ _source: ["id", "name"] },

		// 2) Override index for this search
		{ index: "second-index" },
		{ _source: ["title"] },
	],
});

const first = result.responses[0];
const doc1 = first.hits.hits[0]._source; // { id: number; name: string }

const second = result.responses[1];
const doc2 = second.hits.hits[0]._source; // { title: string }

Notes:

  • Responses preserve per-search typing: each responses[i] matches the corresponding header/body pair.
  • responses[i] can be an error object if that search failed.
const ids = ["batman", "superman"];
const searches = ids.flatMap((id) => [{}, { query: { match: { id } }, _source: ["name"] }] as const);
//    ^? [{}, { query: { match: { id: "batman" | "superman" } }, _source: ["name"] }]
const result = await client.msearch({ index: "superheroes", searches);

for(const match of result.responses) {
    if(match.hits) {
	const hits = match.hits; // { hits: { _source: { name: string } } }
    }
}

Notes:

  • You can still mix indexes, _searches, but here response[i] will be a union of the types of the responses.

What if the library is missing a feature that you need?

Please open an issue or a PR.

If it's a type error and is urgent, you can add the types manually as you'd do without the library.

const myBrokenQuery = typedEs(client, {
    index: "my-index",
    _source: ["score", "entity_id", "*ate"],
});

const result = await (client as unknown as Client).search<TDocument, TAggregations>(myBrokenQuery); // With the `as Client` cast you are now using the native types

Limitations

  • Query clauses and aggregation field parameters are not fully field-validated yet.
  • Some aggregation functions might be missing.
  • _source accepts typed wildcard patterns and dynamic strings. Wildcards still produce the correct inferred output type.
  • Client setup currently requires as unknown as TypedClient<Indexes> because the official client types are being augmented.
  • index must be a concrete key, _all, or a list of concrete keys. Wildcard index names are not supported yet.

PRs are welcome to fix these limitations.

License

MIT