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;
};
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>
);
}
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>
);
}
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"
With this type, we update our sortingKeys
array to accept only valid property keys from Product
as values:
const sortingKeys: ProductKey[] = ['name', 'manufacturer', 'price'];
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[];
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"
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>
);
}
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"
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>
);
}
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)