DEV Community

Cover image for TypeScript Template Literal Types: Practical Use-Cases for Improved Code Quality
Alex K.
Alex K.

Posted on • Originally published at claritydev.net

TypeScript Template Literal Types: Practical Use-Cases for Improved Code Quality

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)