fodma1
fodma1

Reputation: 3535

TypeScript template literal type to match Title Case

I'm working on a template literal type that'd only match title cased strings. I thought I'd first create a single word matcher using Uppercase<>:

const uppercaseWord: Uppercase<string> = 'U';

but it seems to match lowercase letters too:

const uppercaseWord: Uppercase<string> = 'u';

this one won't throw.

I've tried parametrizing it:

type SingleUpperCase<Str extends string> = `${Uppercase<Str>}`;

const upperCaseWord: SingleUpperCase = 'U';

But the type argument could not be inferred. It works by passing a string literal explicitly:

type SingleUpperCase<Str extends string> = `${Uppercase<Str>}`;

const upperCaseWord: SingleUpperCase<'u'> = 'U';

it works, but I'd need something more generic. Something that matches any uppercase string. If I tried again passing string as the type argument, the error is resolved, but the lowercase letter can be assigned without an error:

type SingleUpperCase<Str extends string> = `${Uppercase<Str>}`;

const upperCaseWord: SingleUpperCase<string> = 'u'; // no error

I wonder if this is even possible given it'd require a regex-like string matching from the compiler.

Upvotes: 3

Views: 3781

Answers (1)

jcalz
jcalz

Reputation: 328186

Aside: I'm not exactly sure what rules you have for "title case"; I don't know if "HTML String" is in title case or not. For what follows, I will assume that you just need to make sure that the first character of the string and the first character after every space (" ") is not lowercase. That means "HTML String" is fine. If you have a different rule, you can adjust the code in the answer below.


There is no specific type in TypeScript that represents title-cased strings. Template literal types don't give this to you; while Uppercase<string> and Capitalize<string> are now their own types as of TypeScript 4.8, implemented in microsoft/TypeScript#47050, they won't enforce handle the spacing requirements.

For a true title-case string type you would need, as you said, something like regular expression validated string types. There is an open issue at microsoft/TypeScript#41160 asking for use cases for such regex-validated types; if the solution below doesn't meet your needs, you might want to comment on that issue with your use case, why it is compelling, and why the alternative solutions don't suffice.


While there is no specific type that works here, you can write a recursive template literal type TitleCase<T> which can be used as a constraint on T. Meaning that T extends TitleCase<T> if and only if T is a title-cased string.

Then, in order to save people from having to annotate their strings with some generic type, you'd write a helper function like asTitleCase() which just returns its input, but produces a compiler error if you pass in a bad string.

So, while your ideal solution here would look like this:

/* THIS IS NOT POSSIBLE
const okay: TitleCase = "This Is Fine"; // okay
const error: TitleCase = "This is not fine"; // error
const alsoError: TitleCase = String(Math.random()); // error
*/

the implementable solution looks like this:

const okay = asTitleCase("This Is Fine"); // no error
const error = asTitleCase("This is not fine"); // error!
// ---------------------> ~~~~~~~~~~~~~~~~~~
// Argument of type '"This is not fine"' is not assignable to 
// parameter of type '"This Is Not Fine"'.

const alsoError = asTitleCase(String(Math.random())); // error!
// Argument of type 'string' is not assignable to parameter of type
// '"Please Use a Title Cased String Literal Here, Thx"'

Again, this is what is implementable, not what is ideal. All uses of title-cased string types will need to gain an extra generic type parameter.

Note that you probably don't need to actually write asTitleCase(...) unless you want to see the error right at the declaration. Presumably you have some function (say, lookupBookTitle()) that cares about title case. If so, you'd just make that function generic and enforce the constraint there. So instead of const str = asTitleCase("XXX"); lookupBookTitle(str);, you'd just write const str = "XXX"; lookupBookTitle(str); The only difference is where the error shows up.

Also, inside the implementation of something like lookupBookTitle(), you should probably just widen the input to string and just treat it as if it's already been validated. Even though T extends TitleCase<T> has the effect of enforcing the constraint on callers, the compiler won't be able to follow the logic when T is an unspecified generic type parameter:

// callers see a function that constrains title to TitleCase
function lookupBookTitle<T extends string>(title: VerifyTitleCase<T>): Book;

// implementer just uses string
function lookupBookTitle(title: string) {  
  const book = db.lookupByTitle(title); 
  if (!book) throw new Error("NO BOOK");
  return book;
}

Anyway, here's the implementation:

type TitleCase<T extends string, D extends string = " "> =
  string extends T ? never :
  T extends `${infer F}${D}${infer R}` ?
  `${Capitalize<F>}${D}${TitleCase<R, D>}` : Capitalize<T>;

The type TitleCase<T, D> splits the string T at the delimiter D, and capitalizes (first character is uppercased) each piece. So it turns a string into a title-cased version of itself:

type X = TitleCase<"the quick brown fox jumps over the lazy dog.">
// type X = "The Quick Brown Fox Jumps Over The Lazy Dog."

Then we can write a VerifyTitleCase<T> type that checks if T extends TitleCase<T>. If so, it resolves to T. If not, it resolves either to TitleCase<T>, or some hard-coded error string that hopefully gives users an idea what went wrong. (There are no "throw types" or "Invalid types" in TypeScript, as requested in microsoft/TypeScript#23689; so using a hard-coded error string literal is a workaround):

type VerifyTitleCase<T extends string> = T extends TitleCase<T> ? T :
  TitleCase<T> extends never ? "Please Use a Title Cased String Literal Here, Thx" :
  TitleCase<T>

And finally, the helper function:

const asTitleCase = <T extends string>(s: VerifyTitleCase<T>) => s;

Playground link to code

Upvotes: 9

Related Questions