Setting Up TypeScript with Cypress

8 min read

Six chapters of TypeScript theory; now to put it to work. Cypress ships with TypeScript support out of the box — no plugins, no extra build step. This lesson takes you from an empty folder to a typed Cypress project with a custom cy.login() command, typed fixtures, and full IDE autocomplete on every chainable. Every snippet here works in a real Cypress 13+ project.

What Cypress gives you for free

Cypress includes its own TypeScript declarations as part of the cypress npm package. The moment you npm install cypress, your editor knows about cy.get, cy.visit, cy.intercept, every chainer assertion — and the types of every argument they accept.

You don't need ts-loader, ts-node, Babel, or any third-party plugin. Cypress bundles its own TypeScript compiler internally and runs your .ts test files directly.

Setting up a fresh project

mkdir cypress-ts-project && cd cypress-ts-project
npm init -y
npm install cypress typescript --save-dev
npx cypress open

The first run of npx cypress open launches the Cypress app and offers to scaffold a project. Pick "E2E Testing" → choose your preferred browser → and Cypress drops a starter folder structure into the project:

cypress/
├── e2e/
├── fixtures/
└── support/
    ├── commands.ts      ← typed custom commands live here
    └── e2e.ts
cypress.config.ts        ← top-level Cypress config (TypeScript)
tsconfig.json            ← project-wide TS config

Cypress auto-generates a cypress/tsconfig.json tuned for test files. Open it and you'll see the key bits:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM"],
    "types": ["cypress", "node"],
    "moduleResolution": "node"
  },
  "include": ["**/*.ts"]
}

The line that matters most is "types": ["cypress"]. That's what wires the cy global, the Cypress namespace, and every chainable assertion into your editor. Without it, cy.get(...) would be a red squiggle.

Your first typed Cypress test

Drop this into cypress/e2e/login.cy.ts:

describe("Login page", () => {
  it("logs in with valid credentials", () => {
    cy.visit("/login");
    cy.get("[data-testid='email']").type("alice@test.com");
    cy.get("[data-testid='password']").type("SecurePass123");
    cy.get("[data-testid='submit']").click();
    cy.url().should("include", "/dashboard");
  });
});

Hover over cy.get in VS Code — the tooltip shows the full overload signature: optional generics for the element type, options object, the chainable return type. Type a typo like cy.gett(...) and the compiler refuses it. Pass a number where a selector is expected and the compiler complains. The protection is automatic.

Custom commands — the killer feature for typed Cypress

Every test repeats the same login dance. Move it into a custom command and the tests get cleaner; type the command and callers get autocomplete on cy.login("alice", "pw"). Here's the pattern that powers most typed Cypress projects:

// cypress/support/commands.ts
 
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
    }
  }
}
 
Cypress.Commands.add("login", (email: string, password: string) => {
  cy.get("[data-testid='email']").type(email);
  cy.get("[data-testid='password']").type(password);
  cy.get("[data-testid='submit']").click();
});
 
export {};

Two halves to read carefully:

  1. The declare global block uses interface declaration merging — the killer feature interfaces have over type aliases. Cypress's official Chainable interface lives inside node_modules/cypress; this block adds login to it from your own file. Now everywhere in your project, cy.login(...) autocompletes as a real method on the chainable.
  2. Cypress.Commands.add registers the runtime implementation. If the implementation's parameters drift from the declared signature, the compiler flags it.

The empty export {}; at the bottom turns the file into a module — needed for declare global to take effect. Without it, the declarations would be local to the file.

Typing fixtures

Cypress's cy.fixture() returns a chainable wrapping the parsed JSON. By default that's typed as unknown. Apply your interface inside the .then() to get the real shape:

interface UserFixture {
  name: string;
  email: string;
  password: string;
}
 
it("logs in using a fixture", () => {
  cy.visit("/login");
  cy.fixture<UserFixture>("user.json").then((user) => {
    cy.login(user.email, user.password);
    //                        ↑ autocomplete works on user
  });
});

cy.fixture<UserFixture>("user.json") is the typed form — the generic argument tells Cypress what shape to type the resolved value as. Pair it with the typed cy.login from the previous section and the whole test reads as a clean sequence of typed calls.

Cypress + TypeScript at a glance

Step 1 of 5

Install

npm install cypress typescript — Cypress ships its own TypeScript types in the package, no plugins required.

A small Cypress + TS project, complete

Putting it all together in one file:

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): Chainable<void>;
      seedUser(role: "admin" | "tester" | "viewer"): Chainable<void>;
    }
  }
}
 
Cypress.Commands.add("login", (email, password) => {
  // parameter types inferred from the declared Chainable signature
  cy.get("[data-testid='email']").type(email);
  cy.get("[data-testid='password']").type(password);
  cy.get("[data-testid='submit']").click();
});
 
Cypress.Commands.add("seedUser", (role) => {
  cy.task("db:seedUser", role);
});
 
export {};
// cypress/e2e/admin.cy.ts
interface AdminFixture { email: string; password: string }
 
describe("Admin dashboard", () => {
  it("shows the admin tools after login", () => {
    cy.seedUser("admin");
    cy.fixture<AdminFixture>("admin.json").then((user) => {
      cy.visit("/login");
      cy.login(user.email, user.password);
      cy.contains("Admin Tools").should("be.visible");
    });
  });
});

Custom commands typed, fixture typed, every selector and assertion type-checked. The Cypress commands cheat sheet has the full list of built-in chainers — every one is fully typed, so autocomplete is the fastest reference.

⚠️ Common mistakes

  • Forgetting declare global and the empty export {}. Without declare global, your interface Chainable declaration is scoped to the file and won't merge with Cypress's. Without export {}, the file isn't a module and declare global doesn't take effect. Both lines are necessary; missing either gives you a "Property 'login' does not exist on type 'Chainable'" error from every test that uses the custom command.
  • Adding "types": ["cypress"] only at the project root and not in cypress/tsconfig.json. Cypress test files live under cypress/ and use the nested tsconfig — that's the one that needs "types": ["cypress"]. Without it, the entire cy global is unrecognised.
  • Relying on cy.fixture("user.json").then((user) => ...) without a generic. The default type of user is unknown (or any on older versions), which means typos in field accesses sail through. Always pass the generic: cy.fixture<UserFixture>("user.json") — or define a runtime validator if the fixture comes from an external system.

🎯 Practice task

Build a fully typed Cypress project. 30-45 minutes.

  1. Create cypress-ts-project and run the install steps from the lesson.
  2. Run npx cypress open and let it scaffold the project. Confirm cypress/tsconfig.json exists with "types": ["cypress"].
  3. In cypress/support/commands.ts, add a typed cy.login(email, password) custom command following the pattern from the lesson.
  4. In cypress/fixtures/, create user.json with { "name": "Alice", "email": "alice@test.com", "password": "test" }. Define an interface UserFixture in your test file and type the fixture with the generic.
  5. Write cypress/e2e/login.cy.ts that visits /login, calls cy.login(...) with the fixture values, and asserts the URL changes. (Use a public app like Cypress's own Kitchen Sink demo if you don't have a target URL handy.)
  6. Trigger every type check. After each, read the error and revert:
    • Call cy.login("alice") (missing password).
    • Call cy.login(123, "pw") (number for email).
    • Read user.userName (typo) inside the .then callback.
  7. Stretch: add a second custom command cy.seedUser(role: "admin" | "tester" | "viewer") and use it in a second test. Confirm typos in the role string are rejected at compile time.

The next lesson covers the same ground for Playwright — different framework, similar wins, and a few patterns Cypress doesn't expose.

// tip to track lessons you complete and pick up where you left off across devices.