In a TypeScript Cypress project, how do you type a custom `cy.approveReport()`?
answer
- TypeScript interfaces are open by design
- Extend the interface Cypress types cy as
- The declaration sits beside the registration
- It has to be wrapped in declare global
- An empty export makes the file a module
basics
~20 sAdd the method to Cypress's global Chainable interface by declaration merging, in the same support file that registers the command. Wrap it in declare global, and make that file a module by adding export {} if it has no imports.
solid answer
~50 sCypress exposes `Chainable` as an interface you extend, so in `cypress/support/commands.ts` — the file your support file imports — you write the declaration next to the registration: `declare global { namespace Cypress { interface Chainable { approveReport(reportId: string): Chainable<JQuery<HTMLElement>> } } }`, alongside the `Cypress.Commands.add('approveReport', ...)` call. Specs then resolve `cy.approveReport()`, IntelliSense shows any JSDoc you attach to the member, and Cypress infers the implementation callback's parameter types from that declaration, so you do not annotate them twice. Two traps account for most failures. `declare global` is legal only inside a **module**, so a `commands.ts` with no top-level import or export fails to compile with an error about augmenting the global scope — adding `export {}` fixes it. And `declare namespace Cypress` without the `declare global` wrapper, in a file that does have imports, augments nothing and leaves the command unknown.
go deeper
Know that a custom command needs a type declaration as well as a registration, and that it goes on the Cypress Chainable interface rather than on cy itself.
Be ready to write the declaration from memory, explain declaration merging, and name the module-versus-script rule that makes declare global legal or not.
Show how you keep declarations honest as commands change — colocating them with the registration, typing the yielded subject precisely, and catching drift in the type-check step of CI.
Take a position on how much type surface a shared step library should expose, and who owns those declarations once several teams add commands to the same support file.
## The interface you are extending Cypress ships its own type definitions, and the `cy` object is typed as `Cypress.Chainable`. Every built-in command is a member of that interface — it is called `Chainable` because commands chain together. TypeScript interfaces are open, so adding your own step is **declaration merging**: you re-open `Cypress.Chainable` and add a member with the signature your command actually has. ```typescript declare global { namespace Cypress { interface Chainable { /** * Approves the expense report row this command is chained onto. * @example cy.get('[data-cy=report-R-91]').approveReport('R-91') */ approveReport(reportId: string): Chainable<JQuery<HTMLElement>> } } } ``` The JSDoc comment is not decoration: it is what an editor shows the next person who types `cy.appr` in a spec, and it is the cheapest documentation a shared step will ever get. ## Where the declaration goes Put it in the same file that registers the command — conventionally `cypress/support/commands.ts`, which the support file imports so that it loads before every spec. Keeping the declaration and the `Cypress.Commands.add()` call adjacent is what stops the two drifting apart when the command grows a parameter. The payoff runs in both directions: - Specs type-check and get IntelliSense on `cy.approveReport('R-91')`. - The **implementation callback's parameter types are inferred from the declaration**, so `Cypress.Commands.add('approveReport', (reportId) => { ... })` gives `reportId` the type `string` implicitly. Annotating it again is redundant and invites the two to disagree. An alternative is a standalone external typings file, where no import or export is needed; the trade is that the declaration now lives away from the code it describes. ## Module versus script — the error nearly everyone hits TypeScript treats a file with at least one top-level `import` or `export` as a **module**, and a file with neither as a **script**. `declare global` is valid only inside a module. So a `commands.ts` that does nothing but call `Cypress.Commands.add()` fails with an error saying augmentations for the global scope can only be directly nested in external modules or ambient module declarations. The fix is one line at the bottom of the file: ```typescript export {} // makes this file a module, so declare global is legal ``` The converse trap is quieter and therefore worse. In a file that *does* have imports, writing `declare namespace Cypress { ... }` **without** the `declare global` wrapper augments a local namespace instead of the global one. Nothing errors, nothing merges, and the spec still reports that `approveReport` does not exist on `Chainable`. ## Subject types for child and dual commands When you register with `prevSubject`, Cypress infers the subject type of the implementation callback from the declaration, so `{ prevSubject: 'element' }` gives you a `JQuery<HTMLElement>` first parameter without an annotation. Overwriting a child or dual command is the case that needs help: you pass two generic parameters so the checker knows what the previous subject is. | Generic form | First generic | Subject inferred as | |---|---|---| | `Cypress.Commands.overwrite<'type', 'element'>` | the command name | `JQuery<HTMLElement>` | | `Cypress.Commands.overwrite<'screenshot', 'optional'>` | the command name | `unknown` | | `Cypress.Commands.overwrite<'scrollTo', 'window'>` | the command name | `Window` | | `Cypress.Commands.overwrite<'trigger', 'document'>` | the command name | `Document` | ## A checklist for adding one 1. Register the command in `cypress/support/commands.ts` and write the `Chainable` member in the same file. 2. Wrap the declaration in `declare global { namespace Cypress { ... } }`, and make sure the file is a module. 3. Give the member a real return type — `Chainable<JQuery<HTMLElement>>` for a step that yields an element, `Chainable<void>` for one that yields nothing useful — because that type is what every caller downstream reasons about. 4. Attach a JSDoc block with an `@example` line showing the call in context. 5. Leave the implementation callback's parameters unannotated and let them infer, so there is exactly one place to change when the signature moves.
- Why does `declare namespace Cypress` sometimes fail to make a Cypress custom command type-check?Because in a file that has imports or exports, that form declares a namespace local to the module rather than augmenting the global one Cypress's own types live in. Nothing merges and nothing errors, so the spec still reports the command as unknown. Wrapping the declaration in `declare global { ... }` targets the right namespace.
- What return type should a Cypress custom command's `Chainable` member declare?Whatever the command actually yields, since that is what the next link in the chain sees. A step that ends by returning a `cy` chain over an element declares `Chainable<JQuery<HTMLElement>>`; one that only performs setup and yields nothing meaningful declares `Chainable<void>`, which usefully stops callers chaining element assertions onto it.
saying these in an interview costs you the question
- Declares the command on cy rather than on Chainable
- Omits declare global and wonders why nothing merges
- Annotates the callback parameters instead of letting them infer
- Says TypeScript support needs a plugin or a config flag