Wednesday, March 13, 2024 · 7 min read
frontend
javascript
typescript
work

Advanced Type Techniques in TypeScript — 05 const Assertions

Precision and Immutability with const Assertions

Have you ever found yourself spending hours debugging unexpected behaviors in your TypeScript codebase? Subtle changes in configurations, state definitions, or API response structures can lead to big headaches. This is where const assertions come to the rescue, offering a way to ease or even avoid that frustration.

What Are const Assertions?

Simply put, the as consts syntax tells TypeScript to treat a variable as a literal type and to infer the most specific type possible. For example:

const status = 'happy' as const;

Now, the status is no longer just any string variable; TypeScript treats it as this exact string 'happy' and nothing else. Moreover, it becomes read-only, preventing any future changes to both the variable’s value and its type. Thou shall not change!

If you ever dealt with state at the frontend level, you may be familiar with this pattern:

type Action = { type: "ADD_USER"; payload: User}

This is good enough so long as you are able to memorize the definitions:

tyconst ActionTypes = {    
    ADD_USER: "ADD_USER",    
    REMOVE_USER: "REMOVE_USER",    
    UPDATE_USER: "UPDATE_USER"    
};

If you’re working in a team setting, a large codebase, or you don’t possess highly superior autobiographical memory, there’s a great chance that you or someone else will end up doing this:

// "NEW_ITEM" is allowed as type is inferred as string    
dispatch({ type: "NEW_USER", payload: "Johnny" });

Then bugs happen, and you begin to wonder why, and there goes your precious time. Imagine locking down these critical parts of your code, ensuring they remain predictable and aligned with your design.

const ActionTypes = {    
    ADD_USER: "ADD_USER",    
    REMOVE_USER: "REMOVE_USER",    
    UPDATE_USER: "UPDATE_USER"    
} as const; // 'as const' narrows down the types from string to the specific string literals  
  
// This type declaration extracts the literal types of the ActionTypes object's values    
type ActionTypes = typeof ActionTypes[keyof typeof ActionTypes];  
  
// Define a union type for the Action, utilizing ActionTypes    
type Action =     
    | { type: ActionTypes.ADD_USER; payload: User }    
    | { type: ActionTypes.REMOVE_USER; payload: string }    
    | { type: ActionTypes.UPDATE_USER; payload: User };

Let’s consider another more practical scenario where we could use const effectively managing user status in our application.

const UserStatusMap = {    
    Online: 'online',    
    Offline: 'offline',    
    Banned: 'banned'    
} as const;  
  
type UserStatus = typeof UserStatusMap[keyof typeof UserStatusMap]  
  
type UserData = {    
    name: string,    
    bio: string,    
    // Enforce that 'status' must be one of the literal values defined in 'UserStatusMap'    
    status: UserStatus,    
}  
  
// Type guard for runtime validation, ensuring data conforms to type 'UserData'    
function isUserData(data: unknown): data is UserData {    
    return typeof data === 'object' &&     
           data !== null &&    
           'name' in data &&     
           'bio' in data &&     
           'status' in data &&    
           typeof (data as UserData).status === 'string' &&    
           Object.values(UserStatusMap).includes((data as UserData).status);     
}  
  
const getUserData = new Promise((resolve, reject) => {    
    setTimeout(() => {    
        const condition = true;    
        const user = {    
            name: 'John',    
            bio: 'I am John',    
            // Changing this to a different value than UserStatusMap would result to an error    
            status: 'online'    
        };  
  
        if (condition) {    
            resolve(user);    
        } else {    
            reject('Error')    
        }    
    }, 1000)    
})  
  
getUserData.then((data) => {    
    if (!isUserData(data)) {    
        throw new Error('Invalid data');    
    } else {    
        // The 'as UserData' assertion is safe here due to the preceding type guard    
        const response = data as UserData;    
        console.log(response.status)    
    }    
}).catch((error) => {    
    console.log('Something wrong!')    
});

If immutability is a concept that catches your attention, consider diving into my earlier piece on the topic to see if that helps 👇

Protecting Against Unexpected Changes

In a large codebase, complex interactions can mess things up in ways you wouldn’t expect. A small tweak here can cause a cascade of issues there. That’s where const assertions come in, acting like a shield. They help you define and enforce the exact shape of data structures, especially in areas where any deviation from the intended design could lead to bugs.

const assertions are all about keeping things as they are. Once you set it, it is set. This type-level immutability, prevents accidental modifications, thus protecting your code from unexpected side effects. Consider the example below:

const appConfig = {    
  theme: 'dark',     
  language: 'en',    
  version: 1    
} as const;   
  
// Error: Cannot assign to 'theme' because it is a read-only property.    
appConfig.theme = 'light';

It is pretty handy for early error detection, as demonstrated with `appConfig.theme`.

Now, with `const` assertions, TypeScript won’t accept any random string, it demands `'dark'` and nothing else. This keeps you safe from those sneaky, hard-to-spot issues.

function setConfig(key: keyof typeof appConfig, value: typeof appConfig[keyof typeof appConfig]) {    
  // Function logic here     
}  
  
setConfig('theme', 'dark'); // This works    
setConfig('theme', 'light'); // TypeScript error

Under the Hood

When you apply a const assertion to an object or array, TypeScript treats each property as a literal and readonly type. This is different from a regular const declaration where TypeScript infers a more general type. For instance, a const declaration for a string variable would infer the type string, but with a const assertion, the type is the literal value of that string(as discussed in the introduction). This approach ensures that the properties of objects and arrays are treated with the highest specificity, aligning with TypeScript’s goal of providing a robust and predictable type system.

Applying const Assertions Effectively

Now that we’ve looked into the technicalities of const assertions, let’s see how this feature can be pragmatically applied across various scenarios, making our code robust and more maintainable.

Mapping Enums to Strings

If you map enums to specific string representations, const assertions guarantee the values always match up:

enum BlogPostStatus {    
  DRAFT = 'DRAFT',    
  PUBLISHED = 'PUBLISHED',    
  ARCHIVED = 'ARCHIVED'    
}  
  
// TypeScript ensures postStatuses can only contain the defined status strings    
// Error: A 'const' assertions can only be applied to references to enum members, or string, number, boolean, array, or object literals.    
const postStatuses = Object.values(BlogPostStatus) as const;

Handling API Endpoints

const apiRoutes = {    
    userProfile: '/api/users/profile',    
    blogPosts: '/api/blog',    
    settings: '/api/settings'    
} as const;  
  
// Using it in a fetch call    
fetch(apiRoutes.blogPosts)     
     .then(res => res.json())    
     // ... (rest of the fetch logic)

Game State Management

Complex turn-based games or simulations often rely on very specific states that must not change unexpectedly.

const GameState = {    
   PLAYER_TURN: 'PLAYER_TURN',    
   OPPONENT_TURN: 'OPPONENT_TURN',    
   GAME_OVER: 'GAME_OVER'    
} as const;  
  
let currentGameState = GameState.PLAYER_TURN;   
  
// Later in the game logic...    
if (currentGameState === GameState.PLAYER_TURN) {     
  // It's the player's turn to make a move     
}

However, while const assertions provide significant benefits, they also have their limitations. One key limitation is that they can’t be used with variables whose value is not known at compile time. This means const assertions are not suitable for values that are computed at runtime or that come from external sources. Moreover, overusing const assertions can lead to types that are too rigid, potentially making your code less flexible and harder to maintain. Balancing their use is crucial to ensure that the benefits of const assertions don’t become obstacles for future code adaptability.

const Assertions with Indexed Types

const assertions are especially valuable when paired with indexed types, as they bring even more precision and predictability to our code. Indexed types, which let us extract types from objects, become even more powerful when combined with const assertions.

Let’s consider an application configuration scenario:

const appSettings = {    
  apiVersion: 1,     
  defaultTheme: 'light',     
  logLevel: 'error',     
} as const;  
  
type AppSettingKeys = keyof typeof appSettings;     
type DefaultThemeType = typeof appSettings['defaultTheme']; // Type: 'light'  
  
function updateTheme(newTheme: DefaultThemeType) {    
  // ... Logic to update the application's theme ...    
}   
  
// Works!    
updateTheme('light');  
  
// Error: Argument of type '"custom"' is not assignable to parameter of type '"light"'.    
updateTheme('custom');

By combining const assertions with indexed types, we ensure type safety and prevent errors that could arise from incorrect values being used in sensitive parts of our application.

When to Use const Assertions?

While const assertions add a valuable layer of safety, it’s important to use them strategically. As mentioned, overusing them can make your code unnecessarily rigid and harder to modify in the future. Focus on applying them in these key areas:

  • Critical configs: Settings that dictate core application behavior.
  • Complex data structures: Objects with intricate shapes where maintaining consistency is paramount.
  • Code with far-reaching impact: If changing a value could have ripple effects throughout your codebase.
  • Mapping systems with external definitions: Enforce alignment with external APIs or specifications that have strict requirements.

Example of Overuse:

const maxItemsInCart = 100 as const; // Probably Overkill  
  
// Later, if requirements change...    
// Error: Cannot assign to 'maxItemsInCart' because it is a constant.    
maxItemsInCart = 150;

This highlights the importance of balancing the benefits of const assertions with the need to accommodate potential changes in requirements.

Key Takeaways

const assertions, as we have seen, are a simple yet powerful out-of-the-box feature in TypeScript that should be considered when maintaining a robust and predictable codebase. Notably, they also elevate the development experience when paired with a powerful text editor like VSCode.

One of the top reasons we choose TypeScript is for its maintainability. To achieve this, we need to write code that inspires confidence. const assertions are instrumental in establishing this trust. When applied strategically alongside other TypeScript features, they help us reduce the risk of unexpected changes and subtle bugs that can otherwise consume much of our precious debugging time.

See you in the next one 👋!