Branded Types

Use TypeScript branded types in your Cypress tests.

Let's say you need to specify a timeout or wait in your end-to-end Cypress tests. You would use milliseconds

1
2
3
4
// try finding the "selector" elements for up to 1 second
cy.get('selector', { timeout: 1_000 })
// then wait for 5 seconds
.wait(5_000)

For clarity, you could write a helper to convert seconds into milliseconds

1
2
3
4
const seconds = (n) => n * 1000

cy.get('selector', { timeout: seconds(1) })
.wait(seconds(5))

What happens if you accidentally forget the seconds(...) call? You still get a spec that passes your linter

1
2
3
4
5
const seconds = (n) => n * 1000

// oops, GET has a timeout of 1 ms instead of 1000 ms
cy.get('selector', { timeout: 1 })
.wait(seconds(5))

TypeScript system would prevent you from accidentally passing a string into cy.wait(...) for example, but not from mixing the units: milliseconds vs seconds, cents vs dollars, dollars vs euros. If you want to distinguish units, you could use "branded types" approach. You could read about branded types here and here and follow this blog post.

Ms vs Seconds branded types

Let's extend number type with a "brand" property using number & { __brand: T } syntax. We will create one type for milliseconds and another type for seconds. I will put the types into cypress/support/index.d.ts file so it is available in all specs by default.

1
2
3
4
5
6
7
8
9
10
11
// cypress/support/index.d.ts

// time durations branded types
// https://www.learningtypescript.com/articles/branded-types
// https://blog.theodorc.no/posts/branded-types/
// used to represent seconds and milliseconds
// and make it CLEAR which units we are using
// See cypress/e2e/index.ts for conversion functions
type Period<T extends 'ms' | 'seconds'> = number & { __brand: T }
type Milliseconds = Period<'ms'>
type Seconds = Period<'seconds'>

🎓 This blog post is based on the exercise in my "Testing The Swag Store" online course available at cypress.tips/courses.

Now let's create a custom command to "replace" the built-in cy.wait command. Our command will explicitly take Milliseconds argument

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// cypress/support/index.d.ts

type Period<T extends 'ms' | 'seconds'> = number & { __brand: T }
type Milliseconds = Period<'ms'>
type Seconds = Period<'seconds'>

declare namespace Cypress {
interface Chainable {
/**
* Equivalent to cy.wait(ms) but with explicit branded type for milliseconds.
*/
delay(period: Milliseconds): Chainable<undefined>
}
}

The implementation simply delegates to cy.wait

1
2
3
4
5
6
7
8
9
10
11
12
// cypress/support/commands.ts

// implementation for "cy.delay(ms)" command
// note that the branded type for Period is just a number
// thus we can pass it to cy.wait(n) command
Cypress.Commands.add('delay', (period: Milliseconds) => {
const log = Cypress.log({
name: 'delay',
message: `${period} millisecond(s)`,
})
return cy.wait(period, { log: false })
})

Using branded types

Let's use cy.delay(ms) from our spec file

cypress/e2e/misc/wait-ms.cy.ts
1
2
3
4
5
6
7
8
9
describe('Waiting with branded types', () => {
it('waits using seconds and ms', () => {
cy.visit('/')

// write equivalent to "cy.wait(500)"
// using explicit branded type "Milliseconds"
cy.delay(500)
})
})

The code works. At runtime, 500 is a valid argument to pass from cy.delay(500) to cy.wait(500), but what does our TypeScript say about it? It complains:

1
2
3
4
5
6
7
8
9
10
11
> tsc --noEmit --pretty

cypress/e2e/misc/wait-ms.cy.ts:7:14 - error TS2345:
Argument of type 'number' is not assignable to parameter of type 'Milliseconds'.
Type 'number' is not assignable to type '{ __brand: "ms"; }'.

7 cy.delay(500)
~~~


Found 1 error in cypress/e2e/misc/wait-ms.cy.ts:7

We can't simply pass a number where Milliseconds is expected, since a number does not have the & { __brand: T } type part. We need to be explicit: we know 500 is a milliseconds value.

1
2
3
// write equivalent to "cy.wait(500)"
// using explicit branded type "Milliseconds"
cy.delay(500 as Milliseconds)

There are no more type errors

Delay by 500 milliseconds

Let's create a helper to convert seconds into milliseconds

cypress/e2e/index.ts
1
2
3
4
5
6
7
8
9
10
/**
* Returns milliseconds for the given number of seconds.
*/
export function seconds(s: Seconds): Milliseconds {
if (typeof s !== 'number' || s < 1) {
throw new Error(`s() argument must be a positive number, got ${s}`)
}

return (s * 1000) as Milliseconds
}

We need to import this function from our spec to use

cypress/e2e/misc/wait-ms.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { seconds } from '..'

describe('Waiting with branded types', () => {
it('waits using seconds and ms', () => {
cy.visit('/')

// write equivalent to "cy.wait(500)"
// using explicit branded type "Milliseconds"
cy.delay(500 as Milliseconds)
// wait 3 seconds
cy.delay(seconds(3 as Seconds))
})
})

Again, we need to be explicit when introducing 3 - it is a value of seconds.

Branded type predicate

If we want to allow any positive number to be used as milliseconds, we could use a "type predicate"

cypress/e2e/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Branded type predicate function to let TypeScript know
* that the given number is of type Milliseconds and is less than 10 minutes.
* @param n Number to check
* @returns true if the number is Milliseconds
* @example
* ```ts
* const n = 5000
* if (isMilliseconds(n)) {
* // n is now of type Milliseconds
* // there should be no type error
* cy.delay(n)
* }
* ```
*/
export function isMilliseconds(n: number): n is Milliseconds {
return typeof n === 'number' && n > 0 && n < 600_000
}

The part n is Milliseconds tells TypeScript that if the function returns true, the argument can be used as type Milliseconds. Thus in the if branch below, the n value can by used with cy.delay(n) command without a type error.

cypress/e2e/misc/wait-ms.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { seconds, isMilliseconds } from '..'

describe('Waiting with branded types', () => {
it('waits using seconds and ms', () => {
cy.visit('/')

// write equivalent to "cy.wait(500)"
// using explicit branded type "Milliseconds"
cy.delay(500 as Milliseconds)
// wait 3 seconds
cy.delay(seconds(3 as Seconds))

const n = 3_000
if (isMilliseconds(n)) {
// n is now of type Milliseconds
cy.delay(n)
}
})
})

Here is what the TS IntelliSense shows on the const n = 3_000 line

Variable n is declared as a number

And here is what TS IntelliSense shows inside the if (isMilliseconds(n)) branch

Variable n is Milliseconds

Branded type assertion

Using if (isMilliseconds(n)) every time we want to type n as milliseconds is tiresome. Let's add one more utility function to assert that a given argument is of type Milliseconds

cypress/e2e/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Asserts that the given number is of type Milliseconds
* and is less than 10 minutes.
* @param n Number to check
* @example
* ```ts
* const n = 5000
* assertMilliseconds(n)
* // n is now of type Milliseconds
* // there should be no type error
* cy.delay(n)
* ```
*/
export function assertMilliseconds(n: number): asserts n is Milliseconds {
if (typeof n !== 'number' || n < 1 || n >= 600_000) {
throw new Error(`Expected positive number for Milliseconds, got ${n}`)
}
}

The crucial part is the asserts n is Milliseconds syntax. Let's use this function in our spec

cypress/e2e/misc/wait-ms.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { seconds, assertMilliseconds } from '..'

describe('Waiting with branded types', () => {
it('waits using seconds and ms', () => {
cy.visit('/')

// write equivalent to "cy.wait(500)"
// using explicit branded type "Milliseconds"
cy.delay(500 as Milliseconds)
// wait 3 seconds
cy.delay(seconds(3 as Seconds))

const n = 3_000
assertMilliseconds(n)
// n is now of type Milliseconds
cy.delay(n)
})
})

Hover over n after the assertMilliseconds(n) line to confirm that TypeScript "knows" that n is Milliseconds branded type now

TypeScript views variable n as having type Milliseconds after the assertion

The branded types approach works for resolving confusion when using currency, number of machines, ids, etc in your specs. Of course, there are libraries that implement branded types so you do not have to create your own: ts-brand and Effect.

😅 Fun fact: I was so sure I wrote this blog post about branded types many years ago, that I kept searching my blog posts and repos for it in vain. "I remember reading about branded types and writing my own take on using them in end-to-end specs, I am sure my blog has it" - until an exhaustive check showed that I simply read about branded types, but never wrote down my own take.