# typed-query-selector and the case for typed DOM selectors

> typed-query-selector is a type-only TypeScript package that gives querySelector and querySelectorAll precise element types instead of Element. It ships a strict mode for malformed selectors and a standalone parser, at the cost of a heavier type checker.

**g-plane/typed-query-selector** — Better typed `querySelector` and `querySelectorAll`.

- Repository: https://github.com/g-plane/typed-query-selector
- Website: https://www.npmjs.com/package/typed-query-selector
- Stars: 786 · Forks: 12
- Language: TypeScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/g-plane-typed-query-selector

## The type-only shim that replaces the DOM selector signatures

The standard library hands you `Element` no matter what you write. `document.querySelector('div#app')` and `document.querySelector('button#submit')` both come back typed as `Element`, so every property access afterwards needs a cast or a non-null assertion. typed-query-selector removes that friction by leveraging TypeScript 4.1 template literal types to parse the selector string at compile time and map it to a concrete DOM interface.

The mechanism is entirely in the type system. There is no runtime code: the README states that the package contains type definitions only and works at type level. The repository layout matches that claim, with `shim.d.ts`, `strict.d.ts` and `parser.d.ts` sitting at the top level next to their matching test files, and package.json publishing only `*.d.ts` with `main` and `types` both pointing at `shim.d.ts`. The `sideEffects` field is set to false, which fits a package that does nothing at runtime.

The result is a selector that carries its meaning with it. A selector ending in a class or attribute resolves to the element interface that matches the tag name, and combinators are followed to the rightmost element:

```typescript
import 'typed-query-selector'

document.querySelector('div#app') // ==> HTMLDivElement

document.querySelector('div#app > form#login') // ==> HTMLFormElement

document.querySelectorAll('span.badge') // ==> NodeListOf<HTMLSpanElement>

anElement.querySelector('button#submit') // ==> HTMLButtonElement
```

Grouping selectors produce a union, so a query that may match either tag no longer forces a manual annotation:

```typescript
import 'typed-query-selector'

document.querySelector('div, span') // ==> HTMLDivElement | HTMLSpanElement
```

## Installing a package that ships nothing but declarations

The package is published on npm and the README gives a single install command. Because it is a development dependency rather than a runtime one, it belongs in devDependencies:

```bash
npm i -D typed-query-selector
```

After installation the interesting part is how you activate it. The recommended route is the automatic shim, which works by adding the package to the `types` compiler option in tsconfig.json:

```json
{
  "compilerOptions": {
    "types": ["typed-query-selector"]
  }
}
```

This is the path the README points at for TypeScript 6.0 and later, since it needs no change to any source file. The alternative is a side effect import that a bundler picks up:

```typescript
import 'typed-query-selector'
```

The README is explicit that this import is only available if you are using a bundler. Choosing between the two matters more than it looks: the tsconfig `types` array applies to the whole compilation, so in a repository with several packages you would need to set it per package, while the import form is scoped to the files that contain it.

A first real use is simply to write a selector you already had and look at the inferred type. Nothing in your source needs rewriting, and the payoff arrives the moment you hover the expression or let the compiler complain.

## Strict mode turns a malformed selector into never

The default parser is forgiving. An unknown tag or an unparsable fragment resolves to `Element`, which keeps the types usable but hides typos. Strict mode, available from v2.3, adds syntax checking to the parser and returns `never` instead of `Element` when the input string does not parse.

You opt in by swapping the import entry:

```ts
import 'typed-query-selector/strict'

const element = document.querySelector('div[test') // return `never`
```

Or by changing the `types` entry in tsconfig.json, which is a one line diff:

```diff
 {
   "compilerOptions": {
-    "types": ["typed-query-selector"]
+    "types": ["typed-query-selector/strict"]
   }
 }
```

Because the result is `never`, TypeScript blocks any attempt to access properties or pass the value onward, so the error surfaces at the query rather than at the first property read. The README makes the trade-off plain: the feature is off by default, and it does not promise to catch every kind of syntax error, since a more complete parser would become very complex and compilation performance may suffer.

That caveat is the real decision. Strict mode buys you earlier feedback on broken selectors and charges you in type checking time. Projects with large selector surfaces and a tight CI budget should measure before enabling it everywhere.

## The standalone parser entry for reusing selector logic

Sometimes you want the parsing behaviour without patching the DOM methods. The package exposes the parser as its own entry point:

```typescript
import type {
  ParseSelector,
  StrictlyParseSelector, // or use the strict parser
} from 'typed-query-selector/parser'

type MyElement = ParseSelector<'form#login'>
```

The import path matters. The README notes that you should import `typed-query-selector/parser` rather than `typed-query-selector`, because the parser entry does not patch the `querySelector` and `querySelectorAll` functions. That makes the entry safe for type-level utilities, shared types packages, or framework helpers that want to derive an element type from a selector constant without touching global DOM typings.

There is a second type parameter for the fallback case, available from v2.4. When the selector cannot be parsed or cannot be looked up, the default fallback is `Element`, but you can substitute a more useful type:

```ts
import type { ParseSelector } from 'typed-query-selector/parser'

type MyElement = ParseSelector<'unknown-tag', HTMLElement> // ==> HTMLElement
```

For DOM work, `HTMLElement` is usually the better fallback than `Element`, since `Element` lacks the properties that most application code reaches for. This also means the same package can be used for non DOM targets, where a fallback such as a custom element class makes sense.

## Where the parser stops working and hands you a fallback

Every type level selector library has a boundary, and this one draws it at the tag name. An unknown tag falls back to `Element`:

```typescript
import 'typed-query-selector'

document.querySelector('my-web-component') // ==> Element
```

Custom elements therefore get no help from the automatic path. The documented workaround is to state the type yourself at the call site:

```typescript
document.querySelector<MyComponent>('my-web-component') // ==> MyComponent
```

That is a real trade-off. Custom elements are common in modern component libraries, and for them the package only removes the need for a cast you could have written in one line yourself. The value of the library concentrates in the standard HTML tag space, where the mapping from tag to interface is unambiguous.

Selector features that map cleanly include classes, IDs, pseudo classes and attributes, and the type follows the tag rather than the modifier, so `input[name=username]` resolves to `HTMLInputElement` even though the attribute narrows the element at runtime. Combining them works too, with `input.form-control[name=username]` still resolving to `HTMLInputElement`. The `:is()` and `:where()` forms, available from v2.5, produce a union of the matched tags:

```typescript
import 'typed-query-selector'

document.querySelector(':is(div#id, span.class[k=v])') // ==> HTMLDivElement | HTMLSpanElement

document.querySelector(':where(div#id, span.class[k=v])') // ==> HTMLDivElement | HTMLSpanElement
```

The library does narrow the element type, but it does not narrow its fields. An attribute selector that identifies a specific input does not give you a typed `value`.

## What the declarations cost your compiler

The mechanism is a template literal type parse, which means the compiler does the parsing work. The README names the risk directly: a parser that tries to detect every syntax error would become very complex and compilation performance may go bad, which is why strict mode is opt-in.

The repository shows how the author treats that cost. The test script in package.json runs two compilers over the project in one pass:

```json
"test": "tsc -p . && tsc6 -p ."
```

The devDependencies pin a TypeScript 7 line alongside a separate `@typescript/typescript6` package, so the declarations are checked against two compiler versions. That is the level of care you should expect from a package whose entire value is the types it ships, and it is also a hint about how a version bump in TypeScript could affect a consumer: the `types` array in tsconfig.json is what activates the shim, and the README calls the automatic shim the recommended route since TypeScript 6.0.

There is also a straightforward upgrade cost for a project that depends on the package directly: the type of every querySelector call in the codebase changes from `Element` to something narrower. Code that previously relied on `Element` methods, or that used casts to work around a too wide type, can stop compiling. Treat the adoption as a type level change with a review, not a drop-in dependency bump.

## How this differs from hand written querySelector overloads

The alternative most teams already have is a set of manual overloads. A common approach declares a handful of specific signatures for `document.querySelector`, covering the tags you use most, and leaves everything else returning `Element`. The difference is coverage. Manual overloads are exact for the cases you remembered to write down, and silent for the rest, so a missing entry is invisible until someone writes a cast.

typed-query-selector derives the type from the string itself, so the mapping does not depend on anyone enumerating tags. It also handles the cases manual overloads tend to miss, such as attribute selectors, combinators, grouping and the `:is()` and `:where()` forms, and it can be pointed at a strict parser or used on its own through `typed-query-selector/parser`.

The other alternative is to do nothing and keep casting at every call site. That keeps the compiler fast and the diff empty, but the casts are unchecked: a selector that returns a form control used as a generic element is exactly the class of mistake the compiler is there to catch. The pragmatic middle ground is to adopt the package where DOM queries are dense and to leave the strict entry out until the compile time impact has been measured on your own codebase.

## Conclusion

Adopt typed-query-selector if your codebase queries the DOM through TypeScript and you want element types without writing overloads by hand. Skip it if your selectors are computed at runtime, if your type checking budget is already tight, or if you need runtime validation of selector syntax. Before rolling it out, check whether the `types` entry in tsconfig.json can be set per project, and confirm that your CI has room for the longer type checking pass that strict mode introduces.

## FAQ

### Does typed-query-selector add any runtime code to my bundle?

No. The package contains type definitions only and works at type level, publishing just `*.d.ts` files with `sideEffects` set to false.

### How do I enable strict mode in typed-query-selector?

Import `typed-query-selector/strict` instead of `typed-query-selector`, or set the `types` compiler option to `typed-query-selector/strict`. In strict mode an invalid selector such as `div[test` resolves to `never` instead of `Element`.

### What type does typed-query-selector give me for a custom element tag?

An unknown tag falls back to `Element`, so custom elements get no automatic typing. You can override it by passing the type yourself, for example `document.querySelector<MyComponent>('my-web-component')`.

### Can I use the typed-query-selector parser without changing querySelector types?

Yes. Import `typed-query-selector/parser` and use `ParseSelector` or `StrictlyParseSelector` as a type. That entry does not patch the `querySelector` and `querySelectorAll` functions.

### What licence is typed-query-selector released under?

The package is released under the MIT licence, as recorded in package.json and in the LICENSE file at the repository root.

### How often is typed-query-selector updated?

The repository is not archived and the last push was on 2026-09-29. The repository has no GitHub releases, so the published version 2.12.3 in package.json is the main fixed point to check.

## Sources

- [g-plane/typed-query-selector on GitHub](https://github.com/g-plane/typed-query-selector)
- [Issues](https://github.com/g-plane/typed-query-selector/issues)
- [License: MIT](https://github.com/g-plane/typed-query-selector/blob/main/LICENSE)
- [Project website](https://www.npmjs.com/package/typed-query-selector)
- [README](https://github.com/g-plane/typed-query-selector/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/g-plane-typed-query-selector
