TypeScript Template Literal Types: Practical Use-Cases for Improved Code Quality
Updated on · 4 min read|
In recent years, TypeScript has become an indispensable tool for many JavaScript developers, offering type safety, improved code maintainability, and enhanced developer experience. 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.
typescripttype 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';
typescripttype 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';
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.
typescripttype 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"
typescripttype 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"
If you're curious about using TypeScript for typing React form events, you might find this article helpful: TypeScript: Typing Form Events In React.
Dynamically generating keys
Template literal types can be used to create new types with keys generated based on a pattern.
typescripttype Prefix = 'prop'; type Index = '1' | '2' | '3'; type DynamicKey = `${Prefix}${Index}`; type DynamicProps = { [K in DynamicKey]: string; };
typescripttype 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.
typescripttype 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", };
typescripttype 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.
typescripttype 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';
typescripttype 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.
typescripttype Prefix = 'app'; type EventType = 'init' | 'update' | 'destroy'; type EventName = `${Prefix}:${EventType}`; const initEvent: EventName = 'app:init';
typescripttype 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.
typescripttype Unit = 'px' | 'rem'; type NumericValue = '1' | '2' | '3'; type CSSLength = `${NumericValue}${Unit}`; const fontSize: CSSLength = '2rem';
typescripttype 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.
typescripttype Section = 'home' | 'about'; type I18NKey = `translation.${Section}`; const homeKey: I18NKey = 'translation.home';
typescripttype 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.
typescripttype 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);
typescripttype 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 powerful and flexible ways to improve code quality, readability, and maintainability. By leveraging the examples provided in this article, you can create more expressive, robust, and easy-to-manage applications with enhanced type safety. Remember, these examples are only the tip of the iceberg when it comes to the full potential of template literal types.
References and resources
