Skip to main content

Protecting Your Frontend from Impossible Scenarios

Blog Post Deni Junior 5

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.

 

Related Articles

IMG 6358

10 ways to improve your work performance

In this fast-paced world, we all are focused on improving our performance to its best. And due to the emerging remote and hybrid work models, the search for ways to really deliver value to your company and fight procrastination is even more crucial.…

View More
Blip 34

Great resources that’ll make you better at your job

Realizing where you want to go is the first step to follow the best path. Do you want to improve your performance but don’t know where to start? The willpower to do better is something natural and instinctive. But we don’t always have the right…

View More
IMG 6252

20 ways to make your office more eco-friendly

Everyone talks about sustainability and environmental awareness, but the truth is that we spend a good deal of our time in the office not realizing what improvements we can make to make it more eco-friendly. And most important, the impact it can have

View More