String Types For E2E Tests

Use string template types to clarify values in your tests.

Every individual item sold on Mercari.com has an id that looks like m<number>. If you buy several items from the same seller, you get a discount because you buy it as a bundle. Every bundle has its own unique id that looks like b<number>. Both ids are strings, yet they have a certain format that differs. In our tests we don't want to be confused which type we are passing: is it an item id; a bundle id; a random string we pass as id by mistake?

Here is where TypeScript string template literal types come in very handy. Let's define an ItemId type.

1
2
3
4
5
type ItemId = `m${number}`

const id1: ItemId = 'm123' // valid
const id2: ItemId = 'x123' // invalid, does not start with 'm'
const id3: ItemId = 'mabc' // invalid, does not end with a number

TypeScript immediately complains about "x123" and "mabc" strings - these are NOT item ids. My VSCode editor highlights the errors

Item ID type checks

We don't have to run a test to know it does not work; static types check tells us about our mistake.

If we have a runtime ID value (which we could load from a network call for example), we can confirm and typecast it using is a <type> syntax. For example, here is utility function to check if a string is an item id:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type ItemId = `m${number}`

const id1: ItemId = 'm123' // valid

function isItemId(value: string): value is ItemId {
return /^m\d+$/.test(value)
}

if (isItemId(id1)) {
console.log(`${id1} is a valid ItemId`)
} else {
// never happens
console.log('invalid id1', id1)
}

If we look at the "else" branch, we can see the type inference; TS can safely say that "id1" in the "else" branch has "never" type, meaning this code should be unreachable.

Static type check using isItemId function

We can go beyond a predicate, we can write a utility function that throws an exception if it is given an invalid string value; and this function tells TypeScript compiler that the input has certain type afterwards.

1
2
3
4
5
6
7
8
9
10
11
12
13
type ItemId = `m${number}`

const id1: ItemId = 'm123' // valid

function assertItemId(id: string): asserts id is ItemId {
if (!/^m\d+$/.test(id)) {
throw new Error(`Invalid ItemId: ${id}`)
}
}

const id2: string = 'm123'
assertItemId(id2) // Type assertion
// Now id2 is treated as ItemId

What about other strings, like phone numbers? Let's say that every US phone number we have to store must be fully formatted. We can define a type:

1
2
3
4
5
type PhoneNumber = `+1-${number}-${number}-${number}`

const phone1: PhoneNumber = '+1-123-456-7890' // valid
const phone2: PhoneNumber = '123-456-7890' // invalid, missing country code
const phone3: PhoneNumber = '+1-123-4567-890' // invalid, incorrect format, NOT DETECTED

Looks good, but notice my comment on the phone3 - TypeScript does not flag its incorrect format.

Trying to type check phone numbers

Hmm, we do detect the "abridged" phone number strings, yet TS "missed" the obvious problem. Even worse, TS will miss completely wrong numbers!

1
2
3
4
type PhoneNumber = `+1-${number}-${number}-${number}`

const phone1: PhoneNumber = '+1-123-456-7890' // valid
const phone2: PhoneNumber = '+1-1-2-3' // no TS errors!

Look at the PhoneNumber type definition: +1-${number}-${number}-${number} says nothing about how many digits each number part should have. Thus the string "+1-1-2-3" satisfies the type, yet it is a bad phone number string.

Let's step back for a second and look at the bank card codes or credit card CVV codes. We can define a type for a string that is just 4 digits in a row:

1
2
3
4
5
6
7
type digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

type PinCode = `${digit}${digit}${digit}${digit}`

const pin1: PinCode = '1234' // valid
const pin2: PinCode = '123' // invalid, too short
const pin3: PinCode = '12345' // invalid, too long

Look at the TS error given for pin2 and pin3

TS errors for invalid pin codes

TypeScript does NOT tell us "123 is too short" or "123 does not match digit+digit+digit+digit", instead it shows us how such string literal type works. It simply "expands" every possible combination. String type PinCode is a union of ALL strings like 0000 | 0001 | 0002 | ... | 9999. Wow, brute force works!

Ok, let's use the digit type to form a better phone number type.

1
2
3
4
5
6
7
8
9
type digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

// more accurate phone number type DOES NOT WORK
type PhoneNumber =
`+1-${digit}${digit}${digit}-${digit}${digit}${digit}-${digit}${digit}${digit}${digit}`

const phone1: PhoneNumber = '+1-123-456-7890' // valid?
const phone2: PhoneNumber = '123-456-7890' // invalid?
const phone3: PhoneNumber = '+1-123-4567-890' // invalid?

Does the above code work? I don't see any TS errors, hmm. What is the PhoneNumber type, let's hover over it

TypeScript cannot build a string literal type for PhoneNumber

There are too many combinations for the PhoneNumber string literal type, so TS does NOT expand it into +1-000-000-0000 | +1-000-000-0001 | ... - there would be way too many strings in that union to keep track. From my checks, anything longer than 4 digits is not handled, so US 5-digit zip codes are out.

Ok, so we saw string template literal types, how they work under the hood, and even their limitations. One more note, if you need to check how your web application handles invalid inputs, you do NOT need to special types. You can simply use strings. Let's confirm that entering an invalid phone number leads to an error:

1
2
3
4
5
6
7
8
9
10
const invalidNumbers: string[] = [
'123-456-7890',
'+1-123-4567-890',
'+1-1-2-3'
]
invalidNumbers.forEach(number => {
cy.get('#error').should('not.exist')
cy.get('#phone').clear().type(number)
cy.get('#error').should('be.visible')
})

The above Cypress test simply verifies that entering a bad phone number string is handled by the app. Aside from this test, your testing code probably should only accept PhoneNumber. If you must make an exception (for checking the error handling for example), you can be explicit and use @ts-expect-error when calling it:

1
2
3
4
5
6
7
8
const ItemPage = {
visit(id: ItemId, errorPage: boolean = false) {
...
}
}

// @ts-expect-error - confirm the error is shown for non-existent item ids
ItemPage.visit('invalid-item-id', true)

Without @ts-expect-error our TS compiler would not let us pass non-item id string into the page object method ItemPage.visit, but we do want to pass it.

Learn more

We can go beyond string literal types into branded types to represent things like currencies and time durations. I also suggest reading 6 TypeScript Tips to Write Safer, Cleaner Code article.