UPDATED with enhanced support for recursive conditional type
In TypeScript, there are currently no built-in regular-expression-validated string types available up to version 4.7. While Template literal types cover some scenarios, they do not cater to all requirements for regex types. If you encounter a situation where template literal types fall short, consider providing feedback on your use case at microsoft/TypeScript#41160. Expressing the concept of a "string limited to a maximum length of N
characters," where N extends number
, would be straightforward with regex types but challenging with template literals.
Despite these limitations, let's explore how close we can approximate this functionality within TypeScript.
A significant challenge arises in representing the set of all strings shorter than N
characters as a specific type such as StringsOfLengthUpTo<N>
. Essentially, each instance of StringsOfLengthUpTo<N>
is a large union, but due to compiler constraints on unions exceeding ~10,000 members, practical representation is only feasible for strings up to a few characters. For example, supporting the 95 printable ASCII characters limits us to describing StringsOfLengthUpTo<0>
, StringsOfLengthUpTo<1>
, and possibly StringsOfLengthUpTo<2>
, while beyond that capacity becomes an issue due to overwhelming union sizes, like over 800,000 members in the case of StringsOfLengthUpTo<3>
. This limitation forces us to forego specific type definitions.
Alternatively, we can view our requirement as a constraint utilized with generics. Introducing a type similar to TruncateTo<T, N>
, which accepts a T extends string
type and an N extends number
, truncates T
down to N
characters. By enforcing T extends TruncateTo<T, N>
, the compiler provides warnings when dealing with overly lengthy strings.
Prior to TypeScript 4.5, restricted recursion capabilities limited creating TruncateTo<T, N>
for values of N
larger than approximately 20. However, TypeScript 4.5 introduced support for tail recursion elimination on conditional types, enabling the development of TruncateTo<T, N>
by incorporating supplemental accumulator arguments:
type TruncateTo<T extends string, N extends number,
L extends any[] = [], A extends string = ""> =
N extends L['length'] ? A :
T extends `${infer F}${infer R}` ? (
TruncateTo<R, N, [0, ...L], `${A}${F}`>
) :
A
This adaptation employs an A
accumulator to construct the target string alongside an L
array-like accumulator monitoring the growing length of A
, overcoming the absence of a strongly typed length
property in string literal types (see ms/TS#34692 for context). The strategy involves progressing character by character until exhausting the original string or reaching the specified length of N
. Let's observe its application:
type Fifteen = TruncateTo<"12345678901234567890", 15>;
// type Fifteen = "123456789012345"
type TwentyFive = TruncateTo<"123456789012345678901234567", 25>;
// type TwentyFive = "1234567890123456789012345"
Directly implementing T extends TruncateTo<T, N>
raises a circular constraint error. However, devising a helper function like below facilitates bypassing this challenge:
const atMostN = <T extends string, N extends number>(
num: N, str: T extends TruncateTo<T, N> ? T : TruncateTo<T, N>
) => str;
Hence, invoking
atMostN(32, "someStringLiteral")
prompts either success or warning based on the input string's length. Note the peculiar conditional type assignment of
str
, aimed at circumventing the circular constraint. Inferring
T</code from <code>str</code subsequently subjected to scrutiny against <code>TruncateTo<T, N>
ensues potential errors. Here's how it functions:
const okay = atMostN(32, "ThisStringIs28CharactersLong"); // successful match
type Okay = typeof okay; // "ThisStringIs28CharactersLong"
const bad = atMostN(32, "ThisStringHasALengthOf34Characters"); // error!
// -------------------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// '"ThisStringHasALengthOf34Characters"' is not assignable to parameter of type
// '"ThisStringHasALengthOf34Characte"'.
type Bad = typeof bad; // "ThisStringHasALengthOf34Characte"
Is it worth the effort? Perhaps. Previous approaches resorted to non-ideal solutions even for fixed-length validations. Despite improvements in the current methodology, attaining a compile-time validation remains fairly cumbersome. Thus, there might still be instances warranting the adoption of regex-supported string types.
Access the code on the TypeScript playground