DEV Community

Cover image for How to “satisfyingly” use TypeScript‘s type system
Johann Haeger
Johann Haeger

Posted on

How to “satisfyingly” use TypeScript‘s type system

TypeScript‘s type system is a powerful tool, yet it‘s often underutilized by developers. By taking advantage of features like the satisfies operator, we can write cleaner, more robust code and improve maintainability. In this article, we‘ll explore one of many practical cases where the satisfies operator enhances code reliability.

The Objective

Imagine we‘re tasked with building a UI component for sorting products in an app. Users should be able to select the property by which products are sorted. The product model is defined as:

export type Product = {
  id: number;
  name: string;
  manufacturer: string;
  category: string;
  imageUrl: string;
  quantityInStock: number;
  price: number;
};
Enter fullscreen mode Exit fullscreen mode

This type includes both user-friendly properties, like name, and more technical ones, like id. For our sorting component, we‘ll include only an understandable and useful subset of these properties—specifically, name, manufacturer, and price.

In the following code examples, we‘ll use React components to illustrate the concepts. However, the reasoning and approach are not tied to any specific framework and can be applied universally. To keep the focus on how TypeScript‘s type system can improve our code, the examples use simple markup and minimal logic, avoiding unnecessary distraction.

The Brute Force Approach

The most blunt solution is to hardcode the necessary markup. Here‘s how our React component might look:

export default function ProductSort({ setSortingKey }: { setSortingKey: Dispatch<SetStateAction<string>> }) {
  return (
    <div>
      <h3>Sort Products by:</h3>
      <ul>
        <li>
          <button type="button" onClick={() => setSortingKey('name')}>
            Name
          </button>
        </li>
        <li>
          <button type="button" onClick={() => setSortingKey('manufacturer')}>
            Manufacturer
          </button>
        </li>
        <li>
          <button type="button" onClick={() => setSortingKey('price')}>
            Price
          </button>
        </li>
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component receives a setSortingKey function as a prop, which allows us to update the state with the user‘s selected sort key. The markup consists of a list with three buttons, each having its sorting key hardcoded in both the label and the onClick handler.

This approach has clear disadvantages: the markup is repetitive and bloats the component, making it harder to manage and maintain. Additionally, as the number of properties grows, this approach becomes increasingly cumbersome and error prone. The sorting keys themselves are just strings, with no reference or connection to the actual properties of the Product type, leaving room for inconsistencies. Let‘s refine our solution to address these issues.

The Dynamic Markup Approach

To reduce repetition in the markup, we dynamically generate it by iterating over a list of sorting keys. Since we‘re working with a React component, we‘ll use the map method to achieve this. This streamlines the code:

const sortingKeys = ['name', 'manufacturer', 'price'] as const;

export default function ProductSort({ setSortingKey }: { setSortingKey: Dispatch<SetStateAction<string>> }) {
  return (
    <div>
      <h3>Sort Products by:</h3>
      <ul>
        {sortingKeys.map((key) => (
          <li key={key}>
            <button type="button" onClick={() => setSortingKey(key)}>
              {key}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, the sortingKeys array is the single place where we define the keys to be used for sorting. The markup then dynamically creates a list of buttons based on the values in the array.

Since the list of sorting keys should remain constant, we’ve declared sortingKeys as const and used the const assertion (as const). Declaring the variable as const ensures it cannot be reassigned, while the const assertion makes the array and its values immutable. If we need to reuse this array across different parts of the app, we could export it or move it to a shared file for better maintainability.

While this approach reduces repetition and makes the component more maintainable, it still has its drawbacks. The sortingKeys array is just a list of magic strings, meaning there‘s no type safety—we have to manually ensure that the values in the array match the Product type, and this becomes more challenging as the project grows. Let‘s refine our solution further to address this issue.

The Dynamic Markup with Type Checks Approach

In the previous approach, we identified the challenge of keeping our sortingKeys array in sync with the Product type. Issues such as typos, renamed properties, or removed fields can lead to runtime errors that TypeScript can help prevent. To address this, we can leverage TypeScript‘s type system effectively.

First, we create a type that describes all property keys of Product as a string literal union with the keyof operator:

type ProductKey = keyof Product;
// type ProductKey = "id" | "name" | "manufacturer" | "category" | "imageUrl" | "quantityInStock" | "price"
Enter fullscreen mode Exit fullscreen mode

With this type, we update our sortingKeys array to accept only valid property keys from Product as values:

const sortingKeys: ProductKey[] = ['name', 'manufacturer', 'price'];
Enter fullscreen mode Exit fullscreen mode

However, this approach leaves the array mutable, which can lead to unintended changes. To maintain immutability as in the previous example while preserving type safety, we can combine the const assertion with the satisfies operator:

const sortingKeys = ['name', 'manufacturer', 'price'] as const satisfies readonly ProductKey[];
Enter fullscreen mode Exit fullscreen mode

This combination works as follows: The const assertion transforms the array into a tuple of string literal types, ensuring that its elements are treated as specific values rather than generic string types, and making the array immutable. The satisfies operator, on the other hand, validates that the array conforms to the specified type. Importantly, it does so without altering the inferred type of the array. This enables us to retain the exact tuple type on the left-hand side while gaining type-checking benefits. Note that to match the immutability of the array, we have to explicitly include readonly in the type on the right-hand side.

By implementing this pattern, we gain the assurance that any typo or invalid key in the sortingKeys array will trigger a TypeScript error. This type safety becomes particularly valuable in larger projects, where the Product type and the array of keys used for UI and sorting logic may reside in different parts of the codebase, making manual synchronization prone to errors.

To further enhance type safety and flexibility, we can generate a specific type for sorting keys directly from the sortingKeys array:

type SortingKey = (typeof sortingKeys)[number];
// type SortingKey = "name" | "manufacturer" | "price"
Enter fullscreen mode Exit fullscreen mode

When used for the state function setSortingKey in our React component, this ensures that it accepts only valid keys. With these refinements, we can now implement the solution in an updated example:

// file product-list.tsx
import { useState } from 'react';
import { Product } from '';
import ProductSort from './product-sort';

type ProductKey = keyof Product;
export const sortingKeys = ['name', 'manufacturer', 'price'] as const satisfies readonly ProductKey[];
export type SortingKey = (typeof sortingKeys)[number];

export default function ProductList() {
  const [sortingKey, setSortingKey] = useState<SortingKey>('name');
  // …

  return (
    <>
      <ProductSort setSortingKey={setSortingKey} />
      // …
    </>
  );
}

// file product-sort.tsx
import { Dispatch, SetStateAction } from 'react';
import { SortingKey, sortingKeys } from 'product-list';

export default function ProductSort({ setSortingKey }: { setSortingKey: Dispatch<SetStateAction<SortingKey>> }) {
  return (
    <div>
      <h3>Sort Products by:</h3>
      <ul>
        {sortingKeys.map((key) => (
          <li key={key}>
            <button type="button" onClick={() => setSortingKey(key)}>
              {key}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This solution ensures type safety for the sorting keys, making it easier to maintain as the Product type evolves. It also helps eliminate potential errors caused by manual synchronization of keys and type definitions, ensuring a more reliable and scalable codebase.

However, we can take it a step further by restricting the keys to those that are meaningful to the user. In our current approach, any property key from Product can be included in sortingKeys, which might allow keys like imageUrl that don‘t make sense in the context of sorting for a user-facing interface. To focus only on user-relevant properties, we can use one of TypeScript‘s utility types to refine the list of allowed keys:

type ProductKey = Pick<Product, 'name' | 'manufacturer' | 'price'>;
// type ProductKey = "name" | "manufacturer" | "price"
Enter fullscreen mode Exit fullscreen mode

By generating a subset of properties with Pick, we focus only on keys that are meaningful in this context, while excluding irrelevant ones. The keys specified in Pick are type-checked against the Product type, ensuring that any mismatch will result in a TypeScript error.

With this refinement, let‘s take a look at the final code example. By applying the Pick utility type, we can eliminate the intermediate ProductKey type and directly define our SortingKey type for a cleaner implementation:

// file product-list.tsx
import { useState } from 'react';
import { Product } from '';
import ProductSort from './product-sort';

export type SortingKey = Pick<Product, 'name' | 'manufacturer' | 'price'>;
export const sortingKeys = ['name', 'manufacturer', 'price'] as const satisfies readonly SortingKey[];

export default function ProductList() {
  const [sortingKey, setSortingKey] = useState<SortingKey>('name');
  // …

  return (
    <>
      <ProductSort setSortingKey={setSortingKey} />
      // …
    </>
  );
}

// file product-sort.tsx
import { Dispatch, SetStateAction } from 'react';
import { SortingKey, sortingKeys } from 'product-list';

export default function ProductSort({ setSortingKey }: { setSortingKey: Dispatch<SetStateAction<SortingKey>> }) {
  return (
    <div>
      <h3>Sort Products by:</h3>
      <ul>
        {sortingKeys.map((key) => (
          <li key={key}>
            <button type="button" onClick={() => setSortingKey(key)}>
              {key}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Summary

In this article, we‘ve explored how gradually adding type information can help us leverage TypeScript‘s powerful type-checking features. Especially in larger codebases, taking advantage of these features is crucial to minimize overhead due to typos, logical errors, and oversights. As an added benefit, type annotations even provide a degree of self-documentation, making the code easier to understand and maintain. Our final code example demonstrates how the sorting keys in the UI are directly tied to the properties of the Product type. This ensures clarity and reliability, eliminating the pitfalls of earlier iterations where we relied on “magic strings.”

TypeScript offers a rich set of tools, such as the satisfies operator and utility types like Pick, which we‘ve used to refine our solution. The best part about these features is that they come at zero runtime cost. TypeScript‘s type system exists solely during development and is stripped away during transpilation to JavaScript, meaning it doesn‘t bloat your final payload. This makes TypeScript an invaluable asset for maintaining clean, robust, and performant applications.

Top comments (0)