
The user interfaces we see when we browse the web are becoming increasingly intuitive and performant. Now, we have to think about how to support users with faster screen responses and smoother designs. But are we thinking in the same way about how to protect our users from scenarios we did not plan?
This article is not about error screens or user experience. It is about how development-level decisions can protect our customers from impossible scenarios and give the developer more confidence while writing code. Where can we start this journey? At the foundation of modern web applications: requests to the backend.
Trusting at Second Glance
The Problem of Trusting Too Early
Let’s imagine a scenario where, after (sometimes long) discussions, the backend and frontend teams have aligned on how requests are made and what data will be returned. Now it is time to implement the agreed contract. As an example, let’s think about a request to fetch a product data containing the price and the tax to be paid, calculating in the frontend side the total amount using those fields.
// Request of the product to the Backend
const myRequestedProduct = await fetchData('mywebsite.com/product/123');
// Get values from the requested product
const { price, tax } = myRequestedProduct;
// Show to the user the price, tax and the calculated total amount
<section>
<p>Price: {price} €</p>
<p>Tax: {tax} €</p>
<p>Total amount: {price + tax} €</p>
</section>
What happens in the code above if there is a misunderstanding in communication and the backend, instead of sending price, ends up sending originalPrice? In this case, we would stop showing the product’s original value, and we would also compromise the total amount calculation.
Price: € Tax: 12 € Total amount: NaN €
One approach frequently used by developers to map what can be sent by the backend (and also ensuring the types of each variable in our application) is to use TypeScript on the frontend, which gives us more confidence that we are using the correct properties in each variable. However, it cannot be treated as a guarantee that what is being sent by the backend matches the defined type, since it does not perform those validations at runtime in the browser.
// The type definition of what Backend will send
type Product = {
name: string
price: number
tax: number
}
// Request of the product to the Backend,
// trusting that we will receive Product type
const myRequestedProduct = await fetchData<Product>('mywebsite.com/product/123');
// Get values from the requested product
const { price, tax } = myRequestedProduct;
// Show to the user the price, tax and the calculated total amount
<section>
<p>Price: {price} €</p>
<p>Tax: {tax} €</p>
<p>Total amount: {price + tax} €</p>
</section>
Even with the code now using Typescript to map what comes from the backend, the result would be the same.
Price: € Tax: 12 € Total amount: NaN €
Another potential issue is the expected type for each attribute. Even if we change our attribute from price to originalPrice, if the backend sends it as a “string” instead of a “number” (as we defined in the code above), then, even with the correct name, the final calculation can be completely compromised. In our case, this happens because when we try to add two strings, the values are concatenated instead of summed.
// The type definition of what Backend will send
// now fixing the originalPrice attribute, but expecting a number
type Product = {
name: string
originalPrice: number
tax: number
}
// Request of the product to the Backend,
// trusting that we will receive Product type
const myRequestedProduct = await fetchData<Product>('mywebsite.com/product/123');
// Get values from the requested product
const { originalPrice, tax } = myRequestedProduct;
// Show to the user the price, tax and the calculated total amount
<section>
<p>Price: {originalPrice} €</p>
<p>Tax: {tax} €</p>
<p>Total amount: {originalPrice + tax} €</p>
</section>
If the backend sends to us the original price with “20” as value but with string type, the final result would be:
Price: 20 € Tax: 12 € Total amount: 2012 €
Validating Properly What Comes
So how can we validate that the data is arriving in the right format and in real time? We can implement these validations manually, but there are also several libraries that offer different ways to validate if a variable has the correct types (e.g., Zod, Joi, Yup, etc.). For demonstration purposes, we will use Zod, but the choice is entirely up to the development team based on their own preference.
In this context of requesting a product from the backend, we should define a schema (similar to TypeScript types) with what is expected for each attribute, validate if the fetched content matches what is expected, and then proceed to show it to the user:
// Definition of schema with expected attributes and types
const ProductSchema = zod.object({
name: zod.string(),
originalPrice: zod.number(),
tax: zod.number(),
});
// Request of the product to the Backend,
// expecting an "unknown" data first
const myRequestedProduct = await fetchData<unknown>('mywebsite.com/product/123');
// Validate if requested product matches our schema
const validatedProduct = ProductSchema.parse(myRequestedProduct);
// Get values from the requested product
const { originalPrice, tax } = validatedProduct;
// Show to the user the price, tax and the calculated final price
<section>
<p>Price: {originalPrice} €</p>
<p>Tax: {tax} €</p>
<p>Total amount: {originalPrice + tax} €</p>
</section>
In case of the expected type for the original price still being string and not number, when we validate the requested data using our new schema, an error will be thrown in the application informing that the data doesn’t match what was defined:
[
{
"expected": "number",
"code": "invalid_type",
"path": [
"originalPrice"
],
"message": "Invalid input: expected number, received string"
}
]
From there, we can handle the generated error and make the best decision on what to do, based on the product’s scope and the user journey defined by the team:
- Show an error message to the user if it is a critical path
- Report it in an error tracking platform, counting the number of occurrences of this same issue
- Update the defined type to the new possible value if there is no room to negotiate contract changes
- Notify the backend team that the request is returning values that were not expected
Type Assertions Without Validation? Better Avoid!
Following the idea of using TypeScript to define the types of our variables and functions, it’s common to see types being assigned using type assertions without any kind of validation, often because the developer believes that, in that particular path, the variable really does have the correct type and attributes.
Still using the product example, let’s assume we have two possible product types: products that have a size and products that have a color.
type SizedProduct = {
name: string
size: 'S' | 'M' | 'L'
}
type ColoredProduct = {
name: string
color: string
}
// A generic product can be one of them
type Product = SizedProduct | ColoredProduct
From there, let’s imagine that we need to compose the product description using its name and size, formatting it in a function that retrieves the product’s information.
// Format the description of a sized product using the name and size
const formatDescription = (sizedProduct: SizedProduct): string => {
return `${sizedProduct.name} ${sizedProduct.size}`
}
// Function that receives a product an tries to compose the product information
const getProductInfo = (product: Product) => {
const description = formatDescription(product);
// The rest of the function code should continue here ...
}
In the code above, we would already see a TypeScript error since the formatDescription function expects a product with a size, while the getProductInfo function is calling it with a generic product.
Assuming that, for some reason, it isn’t possible to specify the product type used in the latter function (normally we would change the type defined as the parameter of the function, but we’ll keep it as-is for example purposes), if the developer assumes that this flow will always have a sized product, they might use a type assertion when calling the function.
const getProductInfo = (product: Product) => {
// Usage of type assertion to call the function
// saying to Typescript to trust your knowledge that this is a sized product
const description = formatDescription(product as SizedProduct);
}
This kind of assignment is dangerous because the function responsible for getting the product information could be used by another developer that, when reading the function signature with a generic product as parameter, doesn’t figure out that a type assertion is being performed internally. This can lead to an incorrect description being displayed to the user in the application, as we can see in the example below.
MyProductName undefined
Trying to prevent this scenario to happen, instead of using type assertions, it is always safer to ensure that the parameter we are passing actually has the expected type. One way to do this is by validating if the product itself has a size before formatting its description.
// Format the description of a sized product using the name and size
const formatDescription = (sizedProduct: SizedProduct): string => {
return `${sizedProduct.name} ${sizedProduct.size}`
}
// Function that validates if the product is a sized product
const isSizedProduct = (product: Product): product is SizedProduct => {
return 'size' in product;
}
// Function that receives a product an tries to compose the product information
const getProductInfo = (product: Product) => {
// Guaranteeing a flow for not sized product
if (!isSizedProduct(product)) {
// ...
return;
}
// Description that has a validated sized product
const description = formatDescription(product);
// The rest of the function code should continue here ...
}
This way, we can explicitly define what should happen with products that don’t have a size and also ensure that the description is formatted correctly.
Explicit Paths Using Union Types
One way to try ensuring that users don’t see any unexpected scenario is to make explicit in the code what should happen for each possible value being validated. Let’s take a look at this case where, for products with a defined size, we want to show a specific information for smaller sizes and a generic message for the larger ones.
// Still working with sized product type
type SizedProduct = {
name: string
size: 'S' | 'M' | 'L'
}
// Function to get information based on product size
const getSizeInfo = (product: SizedProduct) => {
if (product.size === 'S') {
return getInfoForSmallProducts();
}
return getDefaultProductInfo();
}
In this example, we have a function being called specifically for size S products, while another function is called for the remaining sizes (currently M and L). Now, let’s suppose we introduce a new XS size and we want it to put it in the same path as size S, it would be natural to add it to the first condition, as shown below.
// Now updated with XS size
type SizedProduct = {
name: string
size: 'XS' | 'S' | 'M' | 'L'
}
const getSizeInfo = (product: SizedProduct) => {
// Added XS check here
if (product.size === 'XS' || product.size === 'S') {
return getInfoForSmallProducts();
}
return getDefaultProductInfo();
}
This flow tends to work perfectly. However, we can use TypeScript as an ally to guide us on what to do when a new value is added to a set of possible values for a given attribute.
In the case above, after adding XS to the list of possible sizes, we would not see any indication that something needs to be updated in the function. The developer should have the responsibility to manually find every occurrence of this type and add the logic to handle XS sizes. Without any changes, the flow would remain the same, and the new size which should have a specific information defined would receive a generic one.
How can TypeScript help us here? By using switch statements with a default case that show to us when something was not declared.
// Same function but with new behaviour
const getSizeInfo = (product: SizedProduct) => {
switch (product.size) {
case 'S':
return getInfoForSmallProducts();
case 'M':
case 'L':
return getDefaultProductInfo();
default:
return product.size satisfies never;
}
}
Following this approach, when we declare in the default case that the product size satisfies the never type, we are defining that there are no other possible values that haven’t already been set in the switch statement.
In the moment we add XS to the list of possible values, Typescript will show an error to the developer in the default case, indicating that the XS value was not handled properly. This forces the developer to explicitly define what should happen for the added value.
Type '"XS"' does not satisfy the expected type 'never'.(1360)
Once the new size is included in the switch statement, the error disappears and the behaviour becomes properly defined.
// Now with XS value
const getSizeInfo = (product: SizedProduct) => {
switch (product.size) {
case 'XS':
case 'S':
return getInfoForSmallProducts();
case 'M':
case 'L':
return getDefaultProductInfo();
default:
return product.size satisfies never;
}
}
This approach can be improved if the developer considers this flow as a critical one and wants to throw an application error when an unexpected product size is found. In that case, the default case will throw an error along with performing the type check.
// Function that receives the unexpected value and throws error
const shouldNotBeReached = (value: never) => {
throw new Error(`Unexpected ${value}`);
}
const getSizeInfo = (product: SizedProduct) => {
switch (product.size) {
case 'XS':
case 'S':
return getInfoForSmallProducts();
case 'M':
case 'L':
return getDefaultProductInfo();
default:
// Using the new function here
return shouldNotBeReached(product.size);
}
}
Protecting the Code to Protect the User
All the approaches brought in this article were not presented to be a book of mandatory rules that should be applied. The goal is to show some ways to protect our frontend code, so we can protect our customers from scenarios that should not happen in our application. More than any guideline included here, the most important is to keep the mentality of making clear in our code what is the expected behaviour on each situation to avoid unwanted surprises.
Users are the main reason for an application to exist. Providing a smooth experience, without any impossible scenarios or malformed information, is the best way to retain them.




