Enhance your conventional strings using Template Literal Types

Calin C
Yonder TechBlog
Published in
4 min readApr 10, 2023

What are Template Literals Types?

TypeScript 4.1 introduced an interesting new feature called template literal types. This feature allows developers to use the template literal syntax not only in values but also in types. The template literal syntax facilitates the insertion of values into strings in a readable and straightforward way by utilizing backticks to delimit expressions surrounded by ${…}.

With TypeScript 4.1, programmers can use template literal types to create a new string literal type derived from existing ones. This feature is handy when working with string literals or a union of multiple string literals.

type Direction = 'left' | 'right' | 'up' | 'down';

// Only 'go-left' | 'go-right' | 'go-up' | 'go-down' is allowed
type DirectionCommand = `go-${Direction}`

// ✅ Compiles successfully!
const goodDirection: DirectionCommand = 'go-right';

// 🛑 Compiler error: Type '"go-somewhere"' is not assignable to type '"go-left" | "go-right" | "go-up" | "go-down"'.
const badDirection: DirectionCommand = 'go-somewhere';

How can developers benefit from this feature?

A string may not just be simple freeform text in real-life applications. They can express something, represent a value from a limited set, or only bring business value if built on top of some strict conventions and patterns.
The naive solution is to use a simple string type for everything and to build the convention logic around it:

// Each constant represent error codes translated in different languages
// Starts with an `error` string
// followed by a dash sign `-` and an error code number
// then we have a `:` and a standard ISO language code
const error1 = 'error-134:en';
const error2 = 'error-3:es';
const error3 = 'error-4:ru';

function extractErrorCode(error: string): number {
if (error.length < 0) {
throw Error('error is empty');
}

if (!error.startWith('error')) {
throw Error('Invalid');
}

const errorCodeString = error.substring(
error.indexOf("-") + 1,
error.indexOf(":")
);
return parseInt(errorCodeString);
}

function extractLanguageCode(error: string): string {
if (error.length < 0) {
throw Error('error is empty');
}

if (!error.startWith('error')) {
throw Error('Invalid');
}

return error.substring(error.indexOf(":") + 1);
}

console.log(extractErrorCode(error1)); // Result: 134
console.log(extractErrorCode(error2)); // Result: 3
console.log(extractErrorCode(error3)); // Result: 4

console.log(extractLanguageCode(error1)); // Result: en
console.log(extractLanguageCode(error2)); // Result: es
console.log(extractLanguageCode(error3)); // Result: ru

The code above is correct, and we are happy about it, but there are a lot of caveats in these functions:

  • How can you ensure that the function’s argument will respect the convention? A possible solution is to throw an error if the argument doesn’t match a regex. Most developers find regexes hard to understand, and I don’t blame them.
  • What happens if the convention is changed? How much time will you spend changing each function to match the new rules?
  • Somebody should maintain more than 15 lines of logic code. Can we make it simpler and more reliable? The answer is YES, but we should use the right tools.

Using the right tools: Template Literal Types

Using the code above, we can observe some patterns that need to be respected and embed these conventions into our types. But first, let’s define some types:

// Union between multiple language code strings
type LanguageCode = 'en' | 'es' | 'ru';

// Error pattern definition using Template Literal Types
type TranslatedError = `error-${number}:${LanguageCode}`;

// ✅
const error1: TranslatedError = 'error-134:en';
const error2: TranslatedError = 'error-3:es';
const error3: TranslatedError = 'error-4:ru';

// 🛑 ... not assignable to type ...
const error4: TranslatedError = 'error-something:es'; // wrong number
const error5: TranslatedError = 'fault-4:en'; // wrong pattern
const error6: TranslatedError = 'error-3:english' // wrong language code

You can also infer specific parts types of the template using infer keyword:

type ErrorCode<T extends string> = T extends `error-${infer E}:${LanguageCode}` ? E : never;
type ErrorCode13 = ErrorCode<'error-13:es'>; // Type: '13'

You can also manipulate template literals using some particular types like Capitalize, Uncapitalize, Uppercase, Lowercase:

type VerticalPosition = 'top' | 'bottom';
type HorizontalPosition = 'left' | 'right';
type CombinedPosition = `${VerticalPosition}${Capitalize<HorizontalPosition>}`;
type WidgetPosition = VerticalPosition | HorizontalPosition | CombinedPosition;

// ✅
const top: WidgetPosition = 'top';
const topRight: WidgetPosition = 'topRight';
const bottom: WidgetPosition = 'bottom'

// 🛑
const middle: WidgetPosition = 'topBottom'; // Compile error!

Conclusion

As we can see in the example above, we introduced Template Literal Types in our code to make our strings less error-prone by embedding all the pattern-matching rules inside the type and letting the compiler do the job. As a result, everything will be checked at compile time, making our code more type-safe.

--

--