In recent years, TypeScript has become an indispensable tool for many JavaScript developers, offering type safety, improved code maintainability, and enhanced developer experience with many advanced type features, like generic types, type guards and conditional types. One of the powerful features introduced in TypeScript 4.1 is template literal types which provide greater flexibility and control over string literal types.
In TypeScript, a string literal type is a type that represents a specific set of string values. For example, the type "red" | "green" | "blue"
represents the set of three string values "red"
, "green"
, and "blue"
. Template literal types allow you to perform operations on these string literal types using the same syntax as template literal strings in JavaScript.
In this article, we will explore some effective and practical use cases for TypeScript template literal types, demonstrating how they can enhance code quality and productivity. From generating CSS class names to creating type-safe i18n keys, this post will show you how to harness the full potential of template literal types in your TypeScript projects.
Creating type-safe URL patterns
Creating type-safe URL patterns in TypeScript with template literals is an effective way to ensure the validity of your application's routing structure. This approach allows developers to define precise URL patterns, including parameters and dynamic segments, which can then be safely used throughout the application.
type RouteParams = {
userId: number;
postSlug: string;
};
type RoutePath<T extends keyof RouteParams> = T extends "userId"
? `/users/${RouteParams[T]}/posts`
: T extends "postSlug"
? `/posts/${RouteParams[T]}`
: never;
const userPostsPath: RoutePath<"userId"> = "/users/123/posts";
const postPath: RoutePath<"postSlug"> = "/posts/first-post";
// Type '"/posts/first-post"' is not assignable to type '`/users/${number}/posts`'
const wrongPostPath: RoutePath<"userId"> = "/posts/first-post";
Related to template literals is the concept of tagged templates in JavaScript.
Combining enum and string
This is a key use case that brings more flexibility and precision to TypeScript's strong typing capabilities. Enums are a way of giving more friendly names to sets of numeric values. In this case, by defining the Directions
enum, we can use the ${Directions}_DIRECTION
template literal to ensure that the type can only be one of the specific values: "North_DIRECTION", "South_DIRECTION", "East_DIRECTION", or "West_DIRECTION". This aids in keeping code more understandable and easier to debug, as it is clear what kind of value the variable holds and what the acceptable values are. Moreover, it helps to eliminate many potential errors by ensuring that only specific string patterns are accepted.
enum Directions {
North = "North",
South = "South",
East = "East",
West = "West",
}
type DirectionString = `${Directions}_DIRECTION`;
const go: DirectionString = "North_DIRECTION";
Generating CSS class names
Template literal types can also be used to generate CSS class names in TypeScript. By defining a type that represents a set of class names, you can use template literal types to create a type-safe way to generate class names based on some input variables. This can help avoid errors caused by typos or incorrect class names in your CSS files. For example, you could define a type for all the valid button sizes in your application, and use template literal types to generate a class name for a button based on its size.
type ButtonSize = "small" | "medium" | "large";
type ButtonClassNames<T extends ButtonSize> = `button-${T}`;
function getButtonClassName<T extends ButtonSize>(
size: T,
): ButtonClassNames<T> {
return `button-${size}`;
}
const smallButtonClassName = getButtonClassName("small"); // "button-small"
const mediumButtonClassName = getButtonClassName("medium"); // "button-medium"
const largeButtonClassName = getButtonClassName("large"); // "button-large"
Enforcing specific format
TypeScript's Template Literal Types can be used for enforcing specific string formats, such as a date format. Here, the DateFormat
type is defined as ${number}-${number}-${number}
, which forces the string to follow a 'YYYY-MM-DD' format. This allows developers to ensure that all date-related data in their codebase adhere to this specific format, minimizing errors that might arise from inconsistent date formatting. By doing so, it contributes to data integrity in the application, and the standardization eases data parsing and processing. Any attempt to assign a string not adhering to the 'YYYY-MM-DD' format to a variable of 1 type would result in a TypeScript error, preventing the code from compiling and instantly notifying developers of the mismatch.
type DateFormat = `${number}-${number}-${number}`; // 'YYYY-MM-DD'
const date: DateFormat = "2023-06-27";
Dynamically generating keys
Template literal types can be used to create new types with keys generated based on a pattern.
type Prefix = "prop";
type Index = "1" | "2" | "3";
type DynamicKey = `${Prefix}${Index}`;
type DynamicProps = {
[K in DynamicKey]: string;
};
A more advanced example involves creating a type that adds a prefix to the keys of an object. This approach enables the creation of new types with keys adhering to a specific pattern, maintaining a consistent naming convention. Doing so helps prevent errors caused by typos or incorrect property names. TypeScript will generate a compile-time error if an incorrect key name is used, further enhancing code safety and maintainability.
type KeyPattern<T extends string, U extends Record<string, string | number>> = {
[P in `${T}${Capitalize<Extract<keyof U, string>>}`]: U[P extends `${T}${Capitalize<infer K>}`
? K
: never];
};
type User = {
id: number;
name: string;
email: string;
};
type PrefixedUser = KeyPattern<"user", User>;
const user: PrefixedUser = {
userId: 123,
userName: "John",
userEmail: "john@example.com",
};
Building complex type names
Complex type names can be created by combining multiple type parts. This is particularly useful when working with Redux action types.
type BaseType = "User";
type Action = "Create" | "Update" | "Delete";
type TypeName = `${Action}${BaseType}`;
const create: TypeName = "CreateUser";
// Type '"ModifyUser"' is not assignable to type '"CreateUser" | "UpdateUser" | "DeleteUser"'
const modify: TypeName = "ModifyUser";
The same approach can be used to build event names based on a prefix and event type.
type Prefix = "app";
type EventType = "init" | "update" | "destroy";
type EventName = `${Prefix}:${EventType}`;
const initEvent: EventName = "app:init";
Representing CSS values
To have more control over what kinds of values are available, template literal types can be used to build CSS length units.
type Unit = "px" | "rem";
type NumericValue = "1" | "2" | "3";
type CSSLength = `${NumericValue}${Unit}`;
const fontSize: CSSLength = "2rem";
This could be useful if you don't allow certain types of units, like em
, in your design system.
Type-safe i18n keys
As a variation of the previous approach, template literal types can be used to generate i18n keys, ensuring that translations are accurately typed. This technique enhances the consistency and reliability of internationalized applications by enforcing type safety on translation keys.
type Section = "home" | "about";
type I18NKey = `translation.${Section}`;
const homeKey: I18NKey = "translation.home";
Type-safe attribute selectors
Template literal types can be used to create type-safe attribute selectors for DOM elements, ensuring that only valid attributes are used.
type Attribute = "id" | "class" | "data-test";
type AttributeSelector = `[${Attribute}]`;
function queryElementByAttribute<T extends HTMLElement>(
selector: AttributeSelector,
): T[] {
const elements = document.querySelectorAll<T>(selector);
return Array.from(elements);
}
const idSelector: AttributeSelector = "[id]";
const elementsById = queryElementByAttribute<HTMLDivElement>(idSelector);
const dataTestSelector: AttributeSelector = "[data-test]";
const elementsByDataTest =
queryElementByAttribute<HTMLButtonElement>(dataTestSelector);
In this example, we've created a utility function queryElementByAttribute
that takes an AttributeSelector
as a parameter. This ensures that only valid attribute selectors can be used when querying the DOM. On top of improved maintainability and readability, this code also enables better autocompletion support in code editors, as editors can suggest valid attribute selectors based on the defined types.
Conclusion
In conclusion, TypeScript's template literal types offer a fascinating way to work with string literal types, providing a blend of flexibility, precision, and control. They can be harnessed effectively to manage a wide array of tasks, from constructing type-safe URL patterns, creating dynamic keys, generating CSS class names, building complex type names, to enforcing type safety on translation keys. By offering increased code safety, better maintainability, and enhanced developer experience, template literal types undoubtedly position TypeScript as an indispensable tool in the JavaScript ecosystem. Remember, these examples are only the tip of the iceberg when it comes to the full potential of template literal types.
References and resources
- Announcing TypeScript 4.1
- MDN: Template literals
- TypeScript Advanced Types: Working with Conditional Types
- TypeScript handbook: Literal Types
- TypeScript handbook: Template Literal Types
- TypeScript website
- TypeScript: Typing Form Events In React
- Understanding and Implementing Type Guards In TypeScript
- Using Generics In TypeScript: A Practical Guide
Top comments (0)