Resource library

QA How-To

Create Cypress Query Commands with TypeScript: Cypress Query Commands TypeScript Tutorial

Follow this cypress query commands typescript tutorial to build retryable, typed custom queries with addQuery, declarations, tests, and debugging tips.

18 min read | 2,780 words

TL;DR

Register a retryable custom Cypress query with Cypress.Commands.addQuery, return a synchronous function that receives the current subject, and declare the method on Cypress.Chainable. Verify both its return value and its retry behavior with a delayed DOM update.

Key Takeaways

  • Use Cypress.Commands.addQuery when a custom command must retry like a built-in query.
  • Return a synchronous query function that derives its result from the current subject.
  • Keep cy commands, promises, and side effects outside the returned query function.
  • Augment Cypress.Chainable so TypeScript recognizes both parent and child query calls.
  • Test retry behavior by rendering the target after a delay, not only by testing an immediate match.
  • Use overwriteQuery only when changing an existing Cypress query is truly necessary.

A cypress query commands typescript tutorial should do more than add another selector shortcut. You need a custom query that participates in Cypress retryability, preserves the current subject, reports useful logs, and remains type-safe in every spec. This tutorial builds that exact result.

The finished command, findByQa, works as either a parent query or a child query. It waits for matching elements through Cypress's normal assertion retry cycle instead of taking a one-time snapshot. For the wider design decisions around commands, isolation, specs, and CI, read the Cypress modern test architecture complete guide.

You will build the command in a small TypeScript Cypress project, prove that it retries when an element appears late, add readable logging, and learn when addQuery is a better fit than Commands.add.

What You Will Build in This Cypress Query Commands TypeScript Tutorial

By the end, you will have:

  • A findByQa(value) parent query that searches the document.
  • A findByQa(value) child query that searches inside the current subject.
  • A TypeScript declaration that gives specs autocomplete and compile-time checking.
  • A safe attribute-value escape helper for selectors containing quotes or backslashes.
  • Component-free HTML fixtures that verify immediate, scoped, multiple, and delayed matches.
  • A focused example of overwriteQuery for teams that need to adapt an existing query.

The main implementation uses Cypress.Commands.addQuery, which is intended for synchronous, retryable queries. The test invokes the result through .should(...), allowing Cypress to rerun the query until the assertion passes or the command times out.

Prerequisites

Use Node.js 22 LTS, Cypress 15.18.1, and TypeScript 6.0. The custom-query API shown here is current for Cypress 15.18.1. The examples use npm, Cypress.Commands.addQuery, and Cypress.Commands.overwriteQuery. Check your installed versions first:

node --version
npm --version
npx cypress version

Create a clean project if you are not adding this to an existing suite:

mkdir cypress-query-demo
cd cypress-query-demo
npm init -y
npm install --save-dev cypress@15.18.1 typescript@6.0
npx cypress open

Choose E2E Testing in the Cypress launchpad and let Cypress scaffold its support files. You should have cypress.config.ts, cypress/support/e2e.ts, and an E2E spec folder. If your repository already has those files, keep its established layout.

This tutorial uses the browser's built-in CSS.escape when possible and includes a simple fallback for restricted test-attribute values. It requires no third-party selector package.

Step 1: Configure a Minimal TypeScript Cypress Project

Start with a small configuration so failures remain easy to understand. Create or update cypress.config.ts:

import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.ts',
  },
  video: false,
})

The examples use cy.visit('about:blank'), so the tutorial can run without an application server. Your real specs can still use baseUrl. Keep Cypress types visible to the files under cypress with cypress/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "types": ["cypress"],
    "strict": true,
    "noEmit": true
  },
  "include": ["**/*.ts"]
}

Strict mode is useful here. It catches a wrong prevSubject assumption, an incorrect return type, or a declaration that does not match how specs call the query.

Verify the step: run npx tsc --project cypress/tsconfig.json. It should finish without diagnostics. Then run npx cypress verify; Cypress should report that its executable is verified.

Step 2: Understand the Query Contract

A Cypress query has two layers. The outer function runs once when Cypress reaches the command in its queue. It captures arguments and creates logs. The outer function returns an inner query function. Cypress can call that inner function repeatedly while downstream assertions are still failing.

The distinction determines what belongs in each layer:

Concern Outer setup function Returned query function
Validate command arguments Yes No
Create a Cypress log Yes No
Read the current subject No Yes
Search the current DOM No Yes
Call cy.get() or another cy command No No
Produce a synchronous result No Yes
Perform network or file I/O No No

A normal custom command registered with Cypress.Commands.add can enqueue Cypress commands. A query registered with addQuery must instead return a synchronous function. Cypress owns the retry loop. If you put a cy command, promise, timer, or mutable side effect inside that returned function, you break the query contract or repeat the side effect unpredictably.

The result can be a DOM element, a jQuery collection, or another synchronous value that downstream Cypress assertions understand. For selector queries, return a jQuery collection.

Verify the step: open the Cypress runner's Command Log for a built-in get or find. Notice that the assertion stays attached to the query. Your custom query will behave the same way after the next steps.

Step 3: Add the TypeScript Chainable Declaration

Create cypress/support/commands.ts. Put the declaration near the implementation so reviewers can see the public API and behavior together:

export {}

declare global {
  namespace Cypress {
    interface Chainable<Subject = any> {
      /**
       * Find elements by their data-qa attribute.
       * Can start a chain or search within the current DOM subject.
       * @example cy.findByQa('save').click()
       * @example cy.get('form').findByQa('save').click()
       */
      findByQa(
        value: string,
      ): Chainable<JQuery<HTMLElement>>
    }
  }
}

The export {} makes the file a module. declare global then augments Cypress's global namespace instead of replacing it. The method returns Chainable<JQuery<HTMLElement>>, which matches a selector result and allows commands such as .click(), .should('have.length', 2), and .first().

The optional Subject = any generic mirrors Cypress's chainable shape without forcing callers to name a subject type. This one declaration supports both cy.findByQa(...) and cy.get(...).findByQa(...). Runtime registration still decides whether both forms are legal.

Import the file from cypress/support/e2e.ts:

import './commands'

Verify the step: temporarily add cy.findByQa('save') to a spec and run npx tsc --project cypress/tsconfig.json. TypeScript should recognize the method. Change the argument to 123; compilation should report that a number is not assignable to string. Restore the string before continuing.

Step 4: Implement the Cypress Query Commands TypeScript Tutorial Query

Add this implementation below the declaration in cypress/support/commands.ts:

export {}

declare global {
  namespace Cypress {
    interface Chainable<Subject = any> {
      findByQa(value: string): Chainable<JQuery<HTMLElement>>
    }
  }
}

const escapeAttributeValue = (value: string): string => {
  if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
    return CSS.escape(value)
  }

  return value
}

Cypress.Commands.addQuery(
  'findByQa',
  function findByQa(value: string) {
    if (!value) {
      throw new Error('findByQa requires a non-empty data-qa value')
    }

    const selector = `[data-qa="${escapeAttributeValue(value)}"]`

    return (subject?: unknown) => {
      Cypress.ensure.isType(
        subject,
        ['optional', 'element', 'document'],
        'findByQa',
        cy,
      )

      const root = subject ? Cypress.$(subject) : Cypress.$(document)
      return root.find(selector)
    }
  },
)

Custom queries receive the previous subject in their returned inner function. With no subject, this implementation wraps document and searches globally. With a subject, it validates an element or document, wraps that value, and searches only its descendants. Cypress.$ is Cypress's bundled jQuery function, so the query returns a compatible collection. Unlike Cypress.Commands.add, addQuery does not accept a prevSubject options object.

The outer function validates value and creates the selector once. The inner function performs only the DOM lookup. Each assertion retry searches the current DOM, which is exactly what lets a late element become visible to the chain.

This implementation intentionally follows .find() semantics. It searches descendants and does not include the subject itself. That makes cy.get('form').findByQa('save') predictable. If you need to include the root, design and name a separate query instead of hiding different semantics behind one method.

Verify the step: rerun npx tsc --project cypress/tsconfig.json. Start Cypress with npx cypress open, load a spec, and confirm findByQa appears as a recognized command rather than failing with findByQa is not a function.

Step 5: Test Parent and Scoped Query Behavior

Create cypress/e2e/find-by-qa.cy.ts:

describe('findByQa query', () => {
  beforeEach(() => {
    cy.visit('about:blank')
    cy.document().then((doc) => {
      doc.body.innerHTML = `
        <main>
          <button data-qa="save">Save page</button>
          <section id="dialog">
            <button data-qa="save">Save dialog</button>
          </section>
        </main>
      `
    })
  })

  it('finds every matching element from the document', () => {
    cy.findByQa('save')
      .should('have.length', 2)
      .and('have.attr', 'data-qa', 'save')
  })

  it('finds descendants inside the current subject', () => {
    cy.get('#dialog')
      .findByQa('save')
      .should('have.length', 1)
      .and('have.text', 'Save dialog')
  })
})

The first test exercises the parent form. The second exercises the child form and proves that the query respects scope. This matters in large pages where the same stable test attribute can appear in a modal, table row, and navigation area. Scope expresses intent without making the selector value artificially unique.

Do not test this behavior by reaching into the command implementation or calling the returned function yourself. Test the public Cypress chain. That catches registration, typing, subject handling, and assertion integration together.

Verify the step: run npx cypress run --e2e --spec cypress/e2e/find-by-qa.cy.ts. Cypress should report two passing tests. If the scoped test returns two buttons, check that you used the passed subject rather than document unconditionally.

Step 6: Prove the Custom Query Retries

An immediate match does not prove you built a query. Add a test where the application changes the DOM after the Cypress chain starts:

it('retries until a delayed element exists', () => {
  cy.visit('about:blank')

  cy.window().then((win) => {
    win.setTimeout(() => {
      const message = win.document.createElement('p')
      message.dataset.qa = 'status'
      message.textContent = 'Processing complete'
      win.document.body.appendChild(message)
    }, 250)
  })

  cy.findByQa('status')
    .should('be.visible')
    .and('have.text', 'Processing complete')
})

The timer represents an application update, not a test delay. The query first returns an empty jQuery collection. The downstream assertion fails, so Cypress reruns the returned query function. After the application adds the paragraph, the next query attempt returns it and both assertions pass.

Avoid cy.wait(300). A fixed wait hides whether retryability works and makes the suite slower. The assertion is the synchronization point. Cypress stops retrying as soon as the required state exists.

For multi-step stateful scenarios, combine query retryability with a deliberate isolation strategy. The Cypress test isolation and multi-user workflows tutorial explains how to prevent state from leaking while preserving realistic sessions.

Verify the step: run the spec in headed mode and watch the assertion wait briefly before passing. Increase the timer beyond the configured command timeout and confirm that Cypress reports a normal timed-out assertion with the custom query in the chain. Restore the short timer afterward.

Step 7: Add Useful Logging Without Breaking Retries

Create the log once in the outer setup function. Update findByQa as follows:

Cypress.Commands.addQuery(
  'findByQa',
  function findByQa(value: string) {
    if (!value) {
      throw new Error('findByQa requires a non-empty data-qa value')
    }

    const selector = `[data-qa="${escapeAttributeValue(value)}"]`
    const log = Cypress.log({
      name: 'findByQa',
      message: value,
      consoleProps: () => ({ selector }),
    })

    return (subject?: unknown) => {
      Cypress.ensure.isType(
        subject,
        ['optional', 'element', 'document'],
        'findByQa',
        cy,
      )

      const root = subject ? Cypress.$(subject) : Cypress.$(document)
      const result = root.find(selector)
      log.set({ $el: result })
      return result
    }
  },
)

The log exists once, while its $el reference can reflect the latest result. consoleProps gives a developer the computed selector when they click the command in the runner. Do not call cy.log() from the query. It enqueues another command and violates the synchronous query contract.

Keep logging proportional. Selector values are usually helpful, but user tokens, passwords, and sensitive fixture data should not be printed. A custom query should remain a narrow lookup primitive, not become a telemetry system.

Verify the step: open the spec in Cypress, click findByQa in the Command Log, and inspect the browser console output. It should show the selector and highlight the yielded element. The delayed-element test should still pass, demonstrating that logging did not interfere with retries.

Step 8: Use overwriteQuery Only for a Clear Compatibility Need

Cypress also provides Cypress.Commands.overwriteQuery for existing queries. Prefer a new, explicit query name because overwriting a built-in command affects every spec and surprises maintainers. A reasonable narrow use is normalizing an existing team query during a migration.

Here is a self-contained example that trims string selectors passed to the built-in get query:

Cypress.Commands.overwriteQuery(
  'get',
  function get(originalFn, selector, options) {
    const normalized =
      typeof selector === 'string' ? selector.trim() : selector

    return originalFn.call(this, normalized, options)
  },
)

The overwrite itself returns the original query function. Calling originalFn.call(this, ...) preserves the query context Cypress uses for timeout and state management. It does not call cy.get(), which would recursively invoke the overwritten command. Keep the original signature and return behavior intact. In most teams, linting or fixing call sites is clearer than globally changing get, so treat this as an API demonstration rather than a default recommendation.

If you do overwrite a query, put a focused regression spec beside the custom-command tests and explain the compatibility reason in code comments. Review the overwrite whenever Cypress is upgraded.

Verify the step: add cy.get(' #dialog ').should('exist') to the fixture spec and confirm it passes with the overwrite. Remove or keep the overwrite according to your team's documented requirement. The findByQa query does not depend on it.

Step 9: Package and Run the Query in CI

Add scripts to package.json so local and CI execution use the same checks:

{
  "scripts": {
    "cy:open": "cypress open",
    "cy:run": "cypress run --e2e",
    "typecheck:cypress": "tsc --project cypress/tsconfig.json",
    "test:cypress": "npm run typecheck:cypress && npm run cy:run"
  }
}

Run the combined command:

npm run test:cypress

Type checking first prevents an invalid chainable declaration from being hidden by a browser run that covers only a subset of specs. The E2E run then proves the runtime registration and retry behavior.

As the suite grows, keep command tests small and separate from product journeys. Use historical runtime data rather than filename guesses when distributing work. The Cypress split specs by historical duration tutorial shows a practical balancing approach, while the Cypress Cloud smart orchestration setup covers managed parallelization and run coordination.

Verify the step: the combined command should exit with status 0, TypeScript should emit no files, and Cypress should report all findByQa specs passing. Run the same command in a clean checkout before relying on it in CI.

Troubleshooting

Problem: cy.findByQa is not a function -> Import ./commands from the configured E2E support file. Confirm supportFile points to that file and that the command registration is not inside a test hook.

Problem: TypeScript says findByQa does not exist on Chainable -> Make the declaration file a module with export {}, wrap the namespace in declare global, and include the support directory in the Cypress TypeScript project. Restart the editor's TypeScript server after changing configuration.

Problem: The query finds an immediate element but misses a delayed one -> Return a function from addQuery and perform the DOM lookup inside that returned function. If you calculate the jQuery collection in the outer function, Cypress keeps returning a stale snapshot.

Problem: Cypress reports that commands cannot run inside a query -> Remove cy.get, cy.wrap, cy.then, and cy.log from the query function. Use Cypress.$ for synchronous DOM lookup and Cypress.log for command logging.

Problem: A scoped query searches the whole document -> Read the subject passed to the inner query function and use it as the root when present. Validate supported subject types with Cypress.ensure.isType. Do not pass command-style prevSubject options to addQuery.

Problem: A selector fails for punctuation in a value -> Escape the attribute value with CSS.escape or adopt a restricted test-attribute naming convention. Never interpolate untrusted text directly into a CSS selector without escaping it.

Common Mistakes and Best Practices

Use these rules during review:

  • Do return a pure, synchronous lookup function from addQuery.
  • Do validate arguments once in the outer setup function.
  • Do use a stable attribute such as data-qa or data-cy as a testing contract.
  • Do test a delayed DOM update so retryability is proven.
  • Do test both parent and child forms when your inner query handles an optional subject.
  • Do keep the TypeScript declaration and runtime registration aligned.
  • Do not enqueue cy commands from the returned function.
  • Do not use a custom query to hide ambiguous or inaccessible application markup.
  • Do not overwrite a built-in query merely to save a few characters.
  • Do not add side effects that repeat whenever Cypress retries.
  • Do not return a raw NodeList when the rest of your suite expects a jQuery subject.

For selector naming guidance, read Cypress data-cy selector patterns. For suite-level coverage decisions, use the end-to-end testing guide. A selector helper should make intent obvious. If it grows options for visibility, exact text, indexes, timeouts, shadow DOM, and fallback selectors, split those responsibilities. A small query is easier to type, retry, document, and replace.

Interview Questions and Answers

Q: How is Cypress.Commands.addQuery different from Cypress.Commands.add?

addQuery registers a synchronous query whose returned function Cypress can rerun while assertions fail. Commands.add registers a custom command and can enqueue other Cypress commands. Choose based on retry semantics, not merely naming.

Q: Why does a query return another function?

The outer call captures arguments and performs one-time setup. Cypress repeatedly invokes the returned function with the current subject so it can obtain a fresh value during assertion retries.

Q: Can you call cy.get() inside a custom query?

No. A query must execute synchronously, while cy.get() adds an asynchronous command to the Cypress queue. Use synchronous browser APIs or Cypress.$ inside the returned query function.

Q: How does a custom query support both parent and child usage?

The inner query function receives the previous subject, which is undefined when the query starts from cy. Handle both cases and validate any supplied subject with Cypress.ensure.isType. The addQuery API does not use the command API's prevSubject registration option.

Q: How do you prove that a custom query retries?

Start the query before the application inserts the target element, then assert on the eventual element without a fixed wait. A passing delayed-DOM test proves Cypress reran the lookup rather than using a stale result.

Q: When should you use overwriteQuery?

Use it only when you must preserve an existing query API while changing query behavior across the suite. Prefer a newly named query when possible because overwriting a built-in affects every caller and increases upgrade risk.

The structured interviewQnA section below the article metadata contains concise model answers you can reuse for preparation.

Where To Go Next

You now have a paste-ready query, declarations, retry tests, and CI commands. Put the helper behind one stable test-attribute convention, then keep product-specific actions in separate commands or page-level abstractions.

Continue with the complete Cypress modern test architecture guide to place queries in the wider suite design. Then learn to balance Cypress specs with historical duration, protect test isolation in multi-user workflows, and configure Cypress Cloud smart orchestration.

Conclusion

The reliable pattern is simple: register with addQuery, perform one-time validation and logging in the outer function, and return a synchronous DOM lookup that Cypress can rerun. Add the matching Chainable declaration so the runtime behavior and TypeScript API tell the same story.

Run the delayed-element test before adopting any custom query broadly. That one test distinguishes a genuinely retryable query from a selector helper that only looks correct when the DOM is already settled.

Interview Questions and Answers

Explain the execution model of Cypress.Commands.addQuery.

The registered outer function runs once and captures arguments, validates input, and may create a log. It returns a synchronous function that receives the current subject. Cypress reruns that returned function while downstream assertions retry, allowing it to yield a fresh value.

What is the difference between addQuery and Commands.add?

addQuery creates a retryable synchronous query and requires the implementation to return a query function. Commands.add creates a custom command that can enqueue Cypress commands. I use addQuery for value or DOM lookup semantics and Commands.add for actions or workflows.

Why should the DOM lookup occur inside the returned query function?

That function is the part Cypress reruns during assertion retries. If the lookup happens outside it, the command captures a stale jQuery collection and cannot observe later DOM changes. Keeping the lookup inside produces a fresh result on every attempt.

How would you make one custom query support parent and child usage?

In the returned function, I would use the supplied subject as the search root when present and the document when absent. I would validate supplied subjects with Cypress.ensure.isType and test both cy.queryName() and cy.get(...).queryName() forms. addQuery does not accept the command API's prevSubject registration option.

How do you add TypeScript support for a custom Cypress query?

I augment the global Cypress namespace and declare the method on Chainable with its parameter and yielded subject types. I keep the file in the Cypress TypeScript include path and make it a module with export empty braces. Then I run a dedicated no-emit type check in CI.

What restrictions apply inside a Cypress query function?

The function must be synchronous and should be free of side effects because Cypress may invoke it repeatedly. It must not call cy commands or return a promise. For a DOM query, I use Cypress.$ or synchronous DOM APIs and return the value directly.

How would you verify retryability rather than only selector correctness?

I would schedule an application-side DOM update after the chain begins, query for the future element, and attach an assertion without a fixed wait. If the test passes, the query observed a later DOM state through retries. I would also test timeout behavior to ensure failures remain readable.

Frequently Asked Questions

What is a Cypress custom query command?

A Cypress custom query is a synchronous command registered with Cypress.Commands.addQuery. It returns a function that Cypress can invoke again while a linked assertion is retrying, so the result can reflect the current DOM.

Should I use addQuery or Commands.add for a selector helper?

Use addQuery when the helper must behave like get or find and provide a fresh synchronous result during retries. Use Commands.add when the implementation needs to enqueue Cypress commands or perform a larger action workflow.

How do I type a Cypress custom query in TypeScript?

Augment the global Cypress namespace and add the method to Cypress.Chainable. Return Chainable<JQuery<HTMLElement>> for a DOM selector query, and ensure the declaration file is included by the Cypress TypeScript configuration.

Why must a Cypress query return a function?

Cypress calls the outer function once for setup and can call the returned function multiple times during assertion retries. Performing the lookup in the returned function prevents stale DOM results.

Can a custom Cypress query contain cy commands?

No. The returned query function must be synchronous and must not enqueue commands such as cy.get, cy.wrap, or cy.log. Use Cypress.$ for DOM lookup and Cypress.log for a Command Log entry.

How can I test that a Cypress custom query retries?

Start the query, have the application add the target element after a short timer, and assert on that element without using cy.wait. The assertion passes only if Cypress reruns the query against the updated DOM.

What is Cypress.Commands.overwriteQuery used for?

overwriteQuery changes the implementation of an existing query while retaining its command name. Use it sparingly for a documented compatibility requirement because the change affects all callers and may require review during Cypress upgrades.

Related Guides