In one of my recent projects, I had to deal with various custom representations of dates as strings, likeAAAA-MM-DD
miAAAAMMDD
. Since these dates are string variables, TypeScript infers therope
default type. While this is not technically incorrect, working with this type definition is complicated, making it difficult to work effectively with these date strings. For example,constant dog = 'alfie'
is also inferred asrope
type.
In this article, I'll show my approach to improve the developer experience and reduce potential errors when writing these date strings. Canfollow along with this essence. Let's start!
Index
- Types of template literals
- Predicate Constraint Type
- Write date strings
- Conclusion
Before we get into the code, let's briefly review the TypeScript functions we'll use to accomplish our goal.types of literal modelsminarrowing via type predicates.
Types of template literals
Introduced in TypeScript v4.1, template literal types share syntax with JavaScript template literals, but are used as types. Type template literals resolve to a union of all string combinations for a given template. This may sound a bit abstract, so let's see it in action:
type Person = 'Jeff' | 'Maria'type Greeting = `hi ${Person}!` // Template literal typeconst validGreeting: Greeting = `hi Jeff!` // // Note that the type of `validGreeting` is the union `"hi Jeff! " | "hello Maria!` const invalidGreeting: Greeting = `bye-bye, Jeff!` // // Write '"bye-bye, Jeff!"' cannot be assigned to type '"hello, Jeff!" | "Hello, Maria!"
Template literal types are very powerful and allow you to perform generic type operations on these types. For example,capitalization:
type Person = 'Jeff' | 'Maria'type Greeting = `hi ${Person}!`type LoudGreeting = Uppercase<Greeting> // Template literal uppercase typeconst validGreeting: LoudGreeting = `HI JEFF!` // const invalidGreeting: LoudGreeting = `hi jeff!` // // Type '"Hello, Jeff!"' cannot be assigned to type '"HELLO, JEFF!" | "HELLO MARIA!"
Predicate Constraint Type
TypeScript does a phenomenal job of constraining types, for example in the following example:
leave age: string | number = getAge(); // `age` is of type `string` | `number`if (typeof age === 'number') { // `age` reduces to type `number`} else { // `age` reduces to type `string`}
That said, when dealing with custom types, it can be useful to tell the TypeScript compiler how to do the narrowing, for example, when we want to constrain to a type after performing run-time validation. In this case, narrowing of type predicates or user-defined type protections are useful.
In the following example,isDog type guard helps to constrain types for animal variablechecking the type property:
type Dog = { type: 'dog' };type Horse = { type: 'horse' };// custom type guard, `pet is Dog` is the function of type isDog(pet: Dog | Horse): pet is Dog { return pet.type === 'dog';}let animal: Dog | Horse = getAnimal(); // `animal` is of type `Dog` | `Horse` if (isDog(animal)) { // `animal` reduces to type `Dog`} else { // `animal` reduces to type `Horse`}
Write date strings
Now that we're familiar with the basics of TypeScript, let's review our date strings. For the sake of brevity, this example will only contain the code forAAAAMMDD
date strings. All code is available atnext essence.
First, we'll need to define the template literal types to represent the union of all date-like strings:
write one to nine = 1|2|3|4|5|6|7|8|9 write zero to nine = 0|1|2|3|4|5|6|7|8|9/** * Years */type YYYY = `19${zeronine}${zeronine}` | `20${zeroToNine}${zeroToNine}`/** * Months */MM type = `0${oneToNine}` | `1${0|1|2}`/** * Days */ DD type = `${0}${oneToNine}` | `${1|2}${zeronine}` | `3${0|1}`/** * YYYYMMDD */type RawDateString = `${YYYY}${MM}${DD}`;const date: RawDateString = '19990223' // const dateInvalid: RawDateString = ' 19990231' //February 31 is not a valid date, but the literal model doesn't know that! const dateWrong: RawDateString = '19990299' // Write error, 99 is not a valid day
As seen in the example above, template literal types help specify the shape of date strings, but there is no actual validation for these dates. Therefore, the compiler signals19990231
as a valid date, even if it is not, since it matches the model type.
Also, by inspecting the above variables likedata
,invalid data
, miwrong date
, you will find that the editor displays the union of all valid strings for these template literals. Although useful, I prefer to definenominal classificationso that the type of valid date strings isdate string
instead of"19000101" | "19000102" | "19000103" | ...
. The nominal type will also be useful when adding user-defined type protections:
type Brand<K, T> = K & { __brand: T };type DateString = Brand<RawDateString, 'DateString'>;const aDate: DateString = '19990101'; // // El tipo 'string' no se puede asignar al tipo 'DateString'
To ensure that ourdate string
type also represents valid dates, we'll set up a user-defined type guard to validate dates and constrain types:
/** * Use `moment`, `luxon` or another date library */const isValidDate = (str: string): boolean => { // ...}; // user-defined type guardfunction isValidDateString(str: string ): str is DateString { return str.match(/^\d{4}\d{2}\d{2}$/) !== null && isValidDate (str);}
Now, let's look at the date string types in some examples. In the code snippets below, user-defined type guarding is applied to constrain the type, allowing the TypeScript compiler to refine types to more specific types than declared. Type protection is then applied in a factory function to create a valid date string from a raw input string:
/** * Usage in type constraint */// valid string format, valid data const date: string = '19990223';if (isValidDateString(date)) { // evaluates to true, `date` is reduces to type `DateString` }// valid string format, invalid date (February does not have 31 days) const dateWrong: string = '19990231';if (isValidDateString(dateWrong)) { // evaluates to false, `dateWrong` is not a valid date, even if its format is YYYYMMDD }/** * Use in function factory */function toDateString(str: RawDateString): DateString { if (isValidDateString(str)) return str; throw new Error(`Invalid date string: ${str}`);} // Valid string format, valid dateconst date1 = toDateString('19990211'); // `date1`, is of type `DateString` // invalid string formatconst date2 = toDateString('asdf'); // Type error: argument of type '"asdf"' cannot be assigned to parameter of type '"19000101" | ...// valid string format, invalid date (February does not have 31 days) const date3 = toDateString('19990231'); // throws error: invalid date string: 19990231
Conclusion
I hope this article sheds some light on what TypeScript is capable of in the context of writing custom strings. Note that this approach also applies to other custom strings, such as customUser ID
,user-XXXX
and other date strings likeAAAA-MM-DD
.
The possibilities are endless when you combine user-defined type protections, template literal strings, and nominal types. Be sure to leave a comment if you have any questions and happy coding!
log rocket: full visibility of your web and mobile applications
log rocketis a front-end application monitoring solution that allows you to reproduce issues as if they were happening in your own browser. Instead of guessing why errors occur or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works seamlessly with any application, regardless of framework, and has plugins to register additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket logs console logs, JavaScript errors, stack traces, network request/responses with headers and bodies, browser metadata, and custom logs. It also leverages the DOM to write HTML and CSS to the page, recreating pixel-perfect video from even the most complex single-page and mobile apps.
try it free.