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 👋!