In TypeScript’s type system, every value is also a type. We call them literal types, and in union with other literal types, you can define a type that is very clear about which values it can accept. Let’s take subsets of string as an example. You can define exactly which strings should be part of your set, and rule out a ton of errors. The other end of the spectrum would be the entire set of strings again.
But what if there is something between? What if we can define types that check if certain string patterns are available, and let the rest be more flexible? String template literal types do exactly that. They allow us to define types where certain parts of a string are pre-defined, the rest is open and flexible for a variety of uses.
But even more, in conjunction with conditional types, it’s possible to split strings into bits and pieces and reuse the same bits for new types. This is an incredibly powerful tool, especially if you think about how much code in JavaScript relies on patterns within strings.
In this chapter, we look at a variety of use-cases for string template literal types. From following simple string patterns to extracting parameters and types based on format strings, you will see the enabling power of parsing strings as types.
But we keep it real. Everything you see here comes from real-world examples. What you can accomplish with string template literal types seem endless, though. People abusing the type system to write spell checkers or implement SQL parsers, there seems to be no limit to what you can do with this mind-blowing feature.
You are creating a custom event system, and want to make sure every event name follows a convention and starts with "on".
Use string template literal types to describe string patterns.
It’s common in JavaScript event systems to have some sort of prefix that indicates that a particular string is an event. Usually, event or event handler strings start with on, but depending on the implementation, this can be different.
You want to create your own event system and want to honor this convention. With TypeScript’s string types it is possible to either accept all possible strings or to subset to a union type of string literal types. While one is too broad, the other one is not flexible enough for our needs. We don’t want to define every possible event name upfront, we want to adhere to a pattern.
Thankfully, a type called string template literal type or just template literal type is exactly what we are looking for. Template literal types allow us to define string literals but leave certain parts flexible enough.
For example, a type that accepts all strings that start with on could look like this.
typeEventName=`on${string}`;
Syntactically, template literal types borrow from JavaScript’s template strings. They start and end with a back-tick, followed by any string. Using the specific syntax ${} allows adding JavaScript expressions, like variables, function calls, and the like to strings.
functiongreet(name:string){return`Hi,${name}!`;}greet("Stefan");// "Hi, Stefan!"
Template literal types in TypeScript are very similar. Instead of JavaScript expressions, they allow us to add a set of values in form of types. A type defining the string representation of all available heading elements in HTML could look like this:
typeLevels=1|2|3|4|5|6;// resolves to "H1" | "H2" | "H3" | "H4" | "H5" | "H6"typeHeadings=`H${Levels}`;
Levels is a subset of number, and Headings reads as “starts with H, followed by a value compatible with Levels“. You can’t put every type in here, only ones that have a string representation.
Let’s go back to EventName.
typeEventName=`on${string}`;
Defined like this, EventName reads like “starts with "on", followed by any string”. This includes the empty string. Let us use EventName to create a simple event system. In the first step, we only want to collect callback functions.
For that, we define a Callback type that is a function type with one parameter: An EventObject. The EventObject is a generic type that contains the value with the event information.
typeEventObject<T>={val:T;};typeCallback<T=any>=(ev:EventObject<T>)=>void;
Furthermore, we need a type to store all registered event callbacks: Events.
typeEvents={[x:EventName]:Callback[]|undefined;};
We use EventName as index access as it is a valid sub-type of string. Each index points to an array of callbacks. With our types defined, we set up an EventSystem class.
classEventSystem{events:Events;constructor(){this.events={};}defineEventHandler(ev:EventName,cb:Callback):void{this.events[ev]=this.events[ev]??[];this.events[ev]?.push(cb);}trigger(ev:EventName,value:any){letcallbacks=this.events[ev];if(callbacks){callbacks.forEach((cb)=>{cb({val:value});});}}}
The constructor creates a new events storage, and defineEventHandler takes an EventName and Callback, and stores it in said events storage. trigger takes an EventName and if callbacks are registered, executes every registered callback with an EventObject.
The first step is done. We now have type safety when defining events.
constsystem=newEventSystem();system.defineEventHandler("click",()=>{});// ^ Argument of type '"click"' is not assignable to parameter//. of type '`on${string}`'.(2345)system.defineEventHandler("onClick",()=>{});system.defineEventHandler("onchange",()=>{});
In Recipe 6.2 we will look at how we can use string manipulation types and key remapping to enhance our system.
You want to provide a watch function that takes any object and adds watcher functions for each property, allowing you to define event callbacks.
Use key re-mapping to create new string property keys. Use string manipulation types to have proper camel casing for watcher functions.
Our event system from Recipe 6.1 is taking shape. We are able to register event handlers and trigger events already. Now we want to add watch functionality. The idea is to extend valid objects with methods for registering callbacks, that are exectued every time a property changes. For example, when we define a person object, we should be able to listen to onAgeChanged and onNameChanged events.
letperson={name:"Stefan",age:40,};constwatchedPerson=system.watch(person);watchedPerson.onAgeChanged((ev)=>{console.log(ev.val,"changed!!");});watchedPerson.age=41;// triggers callbacks
So for each property, there will be a method that starts with on, ends with Changed, and accepts callback functions with event object parameters.
To define the new event handler methods, we create a helper type called WatchedObject<T>, where we add bespoke methods.
typeWatchedObject<T>={[Kinstring&keyofTas`on${K}Changed`]:(ev:Callback<T[K]>)=>void;};
There’s a lot to unpack. Let’s go through it step by step.
We define a mapped type, by iterating over all keys from T. Since we only care about string property keys, we use the intersection string & keyof T to get rid of potential symbols or numbers.
Next, we re-map this key to a new string, defined by a string template literal type. It starts with on, then takes the key K from our mapping process, and appends Changed.
The property key points to a function that accepts a callback. The callback itself has an event object as an argument, and by correctly substituting its generics, we can make sure that this event object contains the original type of our watched object. This means that when we call onAgeChanged, the event object will actually contain a number.
This is already fantastic but lacks significant detail. When we use WatchedObject on person like that, all generated event handler methods lack an uppercase character after on. To solve this, we can use one of the built-in string manipulation types to capitalize string types.
typeWatchedObject<T>={[Kinstring&keyofTas`on${Capitalize<K>}Changed`]:(ev:Callback<T[K]>)=>void;};
Next to Capitalize, Lowercase, Uppercase, and Uncapitalize are also available. If we hover over WatchedObject<typeof person>, we can see what the generated type looks like.
typeWatchedPerson={onNameChanged:(ev:Callback<string>)=>void;onAgeChanged:(ev:Callback<number>)=>void;};
With our types set up, we start with the implementation. First, we create two helper functions.
functioncapitalize(inp:string){returninp.charAt(0).toUpperCase()+inp.slice(1);}functionhandlerName(name:string):EventName{return`on${capitalize(name)}Changed`asEventName;}
We need both helper functions to mimic TypeScript’s behavior of re-mapping and manipulating strings. capitalize changes the first letter of a string to its uppercase equivalent, handlerName adds a prefix and suffix to it. With handlerName we need a little type assertion to signal TypeScript that the type has changed. With the many ways how we can transform strings in JavaScript, TypeScript can’t figure out that this will result in a capitalized version.
Next, we implement the watch functionality in the event system. We create a generic function that accepts any object and returns an object that contains both the original properties and the watcher properties.
To successfully implement triggering of event handlers on property change, we use Proxy objects to intercept get and set calls.
classEventSystem{// cut for brevitywatch<Textendsobject>(obj:T):T&WatchedObject<T>{constself=this;returnnewProxy(obj,{get(target,property){// (1)if(typeofproperty==="string"&&property.startsWith("on")&&property.endsWith("Changed")){// (2)return(cb:Callback)=>{self.defineEventHandler(propertyasEventName,cb);};}// (3)returntarget[propertyaskeyofT];},// set to be done ...})asT&WatchedObject<T>;}}
The get calls we want to intercept are whenever we access the properties of WatchedObject<T>:
They start with on and end with Changed.
If that’s the case, we return a function that accepts callbacks. The function itself adds callbacks to the event storage via defineEventHandler.
In all other cases we do regular property access.
Now, every time we set a value of the original object, we want to trigger stored events. This is why we modify all set calls.
classEventSystem{// ... cut for brevitywatch<Textendsobject>(obj:T):T&WatchedObject<T>{constself=this;returnnewProxy(obj,{// get from above ...set(target,property,value){if(propertyintarget&&typeofproperty==="string"){// (1)target[propertyaskeyofT]=value;// (2)self.trigger(handlerName(property),value);returntrue;}returnfalse;},})asT&WatchedObject<T>;}}
The process is as follows:
Set the value. We need to update the object anyways.
Call the trigger function to execute all registered callbacks.
Please note that we need a couple of type assertions to nudge TypeScript in the right direction. We are creating new objects after all.
And that’s it! Try the example from the beginning to see your event system in action.
letperson={name:"Stefan",age:40,};constwatchedPerson=system.watch(person);watchedPerson.onAgeChanged((ev)=>{console.log(ev.val,"changed!!");});watchedPerson.age=41;// logs "41 changed!!"
String template literal types along with string manipulation types and key re-mapping allow us to create types for new objects on the fly. Powerful tools to make the usage of advanced JavaScript object creation more robust.
You want to create typings for a function that takes a format string and substitutes placeholders with actual values.
Create a conditional type that infers the placeholder name from a string template literal type.
Your application has a way of defining format strings by defining placeholders with curly braces. A second parameter takes an object with substitutions, so for each placeholder defined in the format string, there is one property key with the respective value.
format("Hello {world}. My name is {you}.",{world:"World",you:"Stefan",});
Let’s create typings for this function, where we make sure that your users don’t forget to add the required properties. As a first step, we define the function interface with some very broad types. The format string is of type string, and the formatting parameters are in a Record of string keys and literally any value. We focus on the types first, the function body’s implementation comes later.
functionformat(fmtString:string,params:Record<string,any>):string{throw"unimplemented";}
As a next step, we want to lock function arguments to concrete values or literal types by adding generics. We change the type of fmtString to be of a generic type T, which is a sub-type of string. This allows us to still pass strings to the function, but the moment we pass a literal string, we can analyze the literal type and look for patterns. See Recipe 4.3 for more details.
functionformat<Textendsstring>(fmtString:T,params:Record<string,any>):string{throw"unimplemented";}
Now that we locked in T, we can pass it as type parameter to a generic type FormatKeys. This is a conditional type that will scan our format string for curly braces.
typeFormatKeys<Textendsstring>=Textends`${string}{${string}}${string}`?T:never;
Here, we check if the format string:
Starts with a string, this can also be an empty string.
Contains a {, followed by any string, followed by a }.
Is followed again by any string.
This effectively means that we check if there is exactly one placeholder in the format string. If so, we return the entire format string, if not, we return never.
typeA=FormatKeys<"Hello {world}">;// "Hello {world}"typeB=FormatKeys<"Hello">;// never
FormatKeys can tell us if the strings we pass in are format strings or not, but we are actually much more interested in a specific part of the format string: The piece between the curly braces. Using TypeScript’s infer keyword, we can tell TypeScript that if the format string matches this pattern, please grab whatever literal type you find between the curly braces and put it in a type variable.
typeFormatKeys<Textendsstring>=Textends`${string}{${inferKey}}${string}`?Key:never;
That way, we can extract sub-strings and reuse them for our needs.
typeA=FormatKeys<"Hello {world}">;// "world"typeB=FormatKeys<"Hello">;// never
Fantastic. We extracted the first placeholder name. Now on to the rest. Since there might be placeholders following, we take everything after the first placeholder and store it in a type variable called Rest. This condition will be always true, because either Rest is the empty string, or it contains an actual string that we can analyze again.
We take the Rest and in the true branch, call FormatKeys<Rest> in a union type of Key.
typeFormatKeys<Textendsstring>=Textends`${string}{${inferKey}}${inferRest}`?Key|FormatKeys<Rest>:never;
This is a recursive conditional type. The result will be a union of placeholders, which we can use as keys for the formatting object.
typeA=FormatKeys<"Hello {world}">;// "world"typeB=FormatKeys<"Hello {world}. I'm {you}.">;// "world" | "you"typeC=FormatKeys<"Hello">;// never
Now it’s time to wire up FormatKeys. Since we already locked in T, we can pass it as an argument to FormatKeys, which we can use as an argument for Record.
functionformat<Textendsstring>(fmtString:T,params:Record<FormatKeys<T>,any>):string{throw"unimplemented";}
And with that, our typings are all ready. On for the implementation! The implementation is beautifully inverted to how we defined our types. We go over all keys from params and replace all occurrences within curly braces with the respective value.
functionformat<Textendsstring>(fmtString:T,params:Record<FormatKeys<T>,any>):string{letret:string=fmtString;for(letkinparams){ret=ret.replaceAll(`{${k}}`,params[kaskeyoftypeofparams]);}returnret;}
Notice two particular typings.
We need to annotate ret with string. fmtString is with T. a sub-type of string, thus ret would also be T. This would mean that we couldn’t change values because the type of T would change. Annotating it to a broader string type helps us modify ret.
We also need to assert that the object key k is actually a key of params. This is an unfortunate workaround that is due to some failsafe mechanisms TypeScript has for you. Read more on this topic in Recipe 9.1.
With the learnings from Recipe 9.1, we can redefine format to get rid of some type assertions to reach our final version of the format function.
functionformat<Textendsstring,KextendsRecord<FormatKeys<T>,any>>(fmtString:T,params:K):string{letret:string=fmtString;for(letkinparams){ret=ret.replaceAll(`{${k}}`,params[k]);}returnret;}
Being able to split strings and extract property keys is extremely powerful. TypeScript developers all over the world use this pattern to strengthen types for e.g. web servers like Express. We will see some more examples of how we can use this tool to get better types.
You want to extend the formatting function from Recipe 6.3 with the ability to define types for your placeholders.
Create a nested conditional type, and look up types with a type map.
Let’s extend the example from the previous lesson. We now not only want to know all placeholders but also be able to define a certain set of types with the placeholders. Types should be optional, indicated with a colon after the placeholder name, and be one of JavaScript’s primitive types. We expect to get type errors when we pass in a value that is of the wrong type.
format("Hello {world:string}. I'm {you}, {age:number} years old.",{world:"World",age:40,you:"Stefan",});
For reference, let’s look at the original implementation from Recipe 6.3.
typeFormatKeys<Textendsstring>=Textends`${string}{${inferKey}}${inferRest}`?Key|FormatKeys<Rest>:never;functionformat<Textendsstring>(fmtString:T,params:Record<FormatKeys<T>,any>):string{letret:string=fmtString;for(letkinparams){ret=ret.replace(`{${k}}`,params[kaskeyoftypeofparams]);}returnret;}
To achieve this, we need to do two things:
Change the type of params from Record<FormatKeys<T>, any> to an actual object type that has proper types associated with each property key.
Adapt the string template literal type within FormatKeys to be able to extract primitive JavaScript types.
For the first step, we introduce a new type called FormatObj<T>. It works just as FormatKeys did, but instead of simply returning string keys, it maps out the same keys to a new object type. This requires us to chain the recursion using intersection types instead of a union type (we add more and more properties with each recursion), and to change the breaking condition from never to {}. If we would do an intersection with never, the entire return type becomes never. This way we just don’t add any new properties to the return type.
typeFormatObj<Textendsstring>=Textends`${string}{${inferKey}}${inferRest}`?{[KinKey]:any}&FormatObj<Rest>:{};
FormatObj<T> works the same way as Record<FormatKeys<T>, any>. We still didn’t extract any placeholder type, but we made it easy to set the type for each placeholder now that we are in control of the entire object type.
As a next step, we change the parsing condition in FormatObj<T> to also look out for colon delimiters. If we find a : character, we infer the subsequent string literal type in Type and use it as the type for the mapped-out key.
typeFormatObj<Textendsstring>=Textends`${string}{${inferKey}:${inferType}}${inferRest}`?{[KinKey]:Type}&FormatObj<Rest>:{};
We are very close, there’s just one caveat. We infer a string literal type. This means that if we e.g. parse {age:number}, the type of age would be the literal string "number". We need to convert this string to an actual type. We could do another conditional type, or use a map type as a lookup.
typeMapFormatType={string:string;number:number;boolean:boolean;[x:string]:any;};
That way, we can simply check which type is associated with which key, and have a fantastic fallback for all other strings.
typeA=MapFormatType["string"];// stringtypeB=MapFormatType["number"];// numbertypeC=MapFormatType["notavailable"];// any
Let’s wire MapFormatType up to FormatObj<T>.
typeFormatObj<Textendsstring>=Textends`${string}{${inferKey}:${inferType}}${inferRest}`?{[KinKey]:MapFormatType[Type]}&FormatObj<Rest>:{};
Good, we are almost there! The problem is now that we expect every placeholder to also define a type. We want to make types optional. But our parsing condition explicitly asks form : delimiters, so every placeholder that doesn’t define a type doesn’t produce a property either.
The solution: Do the check for types after we checked for placeholder.
typeFormatObj<Textendsstring>=Textends`${string}{${inferKey}}${inferRest}`?Keyextends`${inferKeyPart}:${inferTypePart}`?{[KinKeyPart]:MapFormatType[TypePart]}&FormatObj<Rest>:{[KinKey]:any}&FormatObj<Rest>:{};
The type reads as follows
Check if there is a placeholder available.
If there is a placeholder available, check if there is a type annotation. If so, map the key to a format type, otherwise map the original key to any.
In all other cases, return the empty object.
And that’s it. There is one little failsafe guard that we can add. Instead of allowing any type for placeholders without a type definition, we can at least expect that the type implements toString(). This ensures that we always get a string representation.
typeFormatObj<Textendsstring>=Textends`${string}{${inferKey}}${inferRest}`?Keyextends`${inferKeyPart}:${inferTypePart}`?{[KinKeyPart]:MapFormatType[TypePart]}&FormatObj<Rest>:{[KinKey]:{toString():string}}&FormatObj<Rest>:{};
And with that, let’s apply the new type to format and change the implementation.
functionformat<Textendsstring,KextendsFormatObj<T>>(fmtString:T,params:K):string{letret:string=fmtString;for(letkinparams){letval=`${params[k]}`;letsearchPattern=newRegExp(`{${k}:?.*?}`,"g");ret=ret.replaceAll(searchPattern,val);}returnret;}
We help ourselves with a regular expression to replace names with potential type annotations. There is no need for us to check types within the function, TypeScript should be enough to help in this case.
What we’ve seen is that conditional types in combination with string template literal types and other tools like recursion and type lookups allow us to specify complex relationships with a couple of lines of code. Our types get better, our code more robust, and it’s a joy for developers to use APIs like this.
You craft an elaborate string template literal type that converts any string to a valid property key. With your setup of helper types, you run into recursion limits.
Use the accumulation technique to enable tail-call optimization.
TypeScript’s string template literal types in combination with conditional types allow you to create new string types on the fly, which can serve as property keys or check your program for valid strings.
They work using recursion, which means that just like a function, you can call the same type over and over again, up to a certain limit.
For example, this type Trim<T> removes whitespaces at the start and end of your string type.
typeTrim<Textendsstring>=Textends`${inferX}`?Trim<X>:Textends`${inferX}`?Trim<X>:T;
It checks if there’s a whitespace at the beginning, infers the rest, and does the same check over again. Once all whitespaces at the beginning are gone, the same checks happen for whitespaces at the end. Once all whitespaces at the beginning and end are gone, it is finished and hops into the last branch: returning the remaining string.
typeTrimmed=Trim<" key ">;// "key"
Calling the type over and over again is recursion, and writing it like that works reasonably well. TypeScript can see from the type that the recursive calls stand on their own, and it can evaluate them as tail-call optimized, which means that it can evaluate the next step of the recursion within the same call stack frame.
If you want to know more about the call stack in JavaScript, Thomas Hunter’s book Distributed Systems with Node.js (ISBN: 9781492077299) gives a great introduction.
We want to use TypeScript’s feature to recursively call conditional types to create a valid string identifier out of any string, by removing whitespace and invalid characters.
First, we write a helper type that is similar to Trim<T> which gets rid of any whitespace it can find.
typeRemoveWhiteSpace<Textendsstring>=Textends`${inferA}${inferB}`?RemoveWhiteSpace<`${Uncapitalize<A>}${Capitalize<B>}`>:T;
It checks if there is a whitespace, infers the strings in front of the whitespace and after the whitespace (which can be empty strings), and calls the same type again with a newly formed string type. It also uncapitalizes the first inference and capitalizes the second inference to create a camel-case-like string identifier.
It does so over and over again until all whitespaces are gone.
typeIdentifier=RemoveWhiteSpace<"Hello World!">;// "helloWorld!"
Next, we want to check if the remaining characters if they are valid. We again use recursion to take a string of valid characters, split them into single string types with only one character, and create a capitalized and uncapitalized version of it.
typeStringSplit<Textendsstring>=Textends`${inferChar}${inferRest}`?Capitalize<Char>|Uncapitalize<Char>|StringSplit<Rest>:never;typeChars=StringSplit<"abcdefghijklmnopqrstuvwxyz">;// "a" | "A" | "b" | "B" | "c" | "C" | "d" | "D" | "e" | "E" |// "f" | "F" | "g" | "G" | "h" | "H" | "i" | "I" | "j" | "J" |// "k" | "K" | "l" | "L" | "m" | "M" | "n" | "N" | "o" | "O" |// "p" | "P" | "q" | "Q" | "r" | "R" | "s" | "S" | "t" | "T" |// "u" | "U" | "v" | "V" | "w" | "W" | "x" | "X" | "y" | "Y" |// "z" | "Z"
We shave off the first character we can find, capitalize it, uncapitalize it and do the same with the rest until there are no more strings left. Note that his recursion can’t be tail-call optimized as we put the recursive call in a union type with the results from each recursion step. Here we would reach a recursion limit when we hit 50 characters (a hard limit from the TypeScript compiler). With basic characters, we are fine! Dodged a bullet.
But we hit the first limits when we are doing the next step, the creation of the Identifier. Here we check for valid characters. First, we call the RemoveWhiteSpace<T> type, which allows us to get rid of whitespaces and camel-cases the rest. Then we check the result against valid characters.
Just like in StringSplit<T>, we shave off the first character but do another type check within inference. We see if the character we just shaved off is one of the valid characters. Then we get the rest. We combine the same string again but do a recursive check with the remaining string. If the first character isn’t valid, then we just call CreateIdentifier<T> with the rest.
typeCreateIdentifier<Textendsstring>=RemoveWhiteSpace<T>extends`${inferAextendsChars}${inferRest}`?`${A}${CreateIdentifier<Rest>}`// ^ Type instantiation is excessively deep and possibly infinite.(2589)_.:RemoveWhiteSpace<T>extends`${inferA}${inferRest}`?CreateIdentifier<Rest>:T;
And here we hit the first recursion limit. TypeScript warns us — with an error — that this type instantiation is possibly infinite and excessively deep. It seems that if we use the recursive call within a string template literal type, this might result in call stack errors and blow up. So TypeScript breaks. It can’t do tail-call optimization here.
CreateIdentifier<T> might still produce correct results, even though TypeScript errors when you write your type. Those are hard-to-spot bugs because they might hit you when you don’t expect them. Be sure to not let TypeScript produce any results when errors happen.
There’s one way to work around it. To activate tail-call optimization, the recursive call needs to stand alone. We can achieve this by using the so-called accumulator technique. Here, we pass a second type parameter called Acc, which is of a type string and is instantiated with the empty string. We use this as an accumulator where we store the intermediate result, passing it over and over again to the next call.
typeCreateIdentifier<Textendsstring,Accextendsstring="">=RemoveWhiteSpace<T>extends`${inferAextendsChars}${inferRest}`?CreateIdentifier<Rest,`${Acc}${A}`>:RemoveWhiteSpace<T>extends`${inferA}${inferRest}`?CreateIdentifier<Rest,Acc>:Acc;
This way, the recursive call is standing on its own again, and the result is the second parameter. When we are done with recursive calls, the recursion-breaking branch, we return the accumulator, as it is our finished result.
typeIdentifier=CreateIdentifier<"Hello Wor!ld!">;// "helloWorld"
There might be more clever ways to produce identifiers from any string, but note that the same thing can hit you deep down in any elaborate conditional type where you use recursion. The accumulator technique is a good way to mitigate problems like this.
You model requests to a back-end as a state machine, going from pending to either error or success. Those states should work for different back-end requests, but the underlying types should be the same.
Use string template literals as discriminants for a discriminated union.
The way you fetch data from a back-end always follows the same structure. You do a request, and it’s pending to be either fulfilled and return some data — success — or to be rejected and returned with an error. For example, to log-in a user, all possible states can look like this:
typeUserRequest=|{state:"USER_PENDING";}|{state:"USER_ERROR";message:string;}|{state:"USER_SUCCESS";data:User;};
When we fetch a user’s order, we have the same states available. The only difference is in the success payload, and in the names of each state, which are tailored to the type of request.
typeOrderRequest=|{state:"ORDER_PENDING";}|{state:"ORDER_ERROR";message:string;}|{state:"ORDER_SUCCESS";data:Order;};
When we deal with a global state handling mechanism (e.g. Redux), we want to differentiate by using identifiers like this. We still want to narrow it down to the respective state types!
TypeScript allows to create discriminated union types where the discriminant is a string template literal type. So we can sum up all possible back-end requests using the same pattern.
typePending={state:`${Uppercase<string>}_PENDING`;};typeErr={state:`${Uppercase<string>}_ERROR`;message:string;};typeSuccess={state:`${Uppercase<string>}_SUCCESS`;data:any;};typeBackendRequest=Pending|Err|Success;
This already gives us an edge. We know that the state property of each union type member needs to start with an uppercase string, followed by an underscore and the respective state as a string. And we can narrow it down to the sub-types just as we are used to.
functionexecute(req:BackendRequest){switch(req.state){case"USER_PENDING":// req: Pendingconsole.log("Login pending...");break;case"USER_ERROR":// req: ErrthrownewError(`Login failed:${req.message}`);case"USER_SUCCESS":// req: Successlogin(req.data);break;case"ORDER_PENDING":// req: Pendingconsole.log("Fetching orders pending");break;case"ORDER_ERROR":// req: ErrthrownewError(`Fetching orders failed:${req.message}`);case"ORDER_SUCCESS":// req: SuccessdisplayOrder(req.data);break;}}
Having the entire set of strings as the first part of the discriminant might be a bit too much. We can subset to a variety of known requests, and make use of string manipulation types to get the right sub-types.
typeRequestConstants="user"|"order";typePending={state:`${Uppercase<RequestConstants>}_PENDING`;};typeErr={state:`${Uppercase<RequestConstants>}_ERROR`;message:string;};typeSuccess={state:`${Uppercase<RequestConstants>}_SUCCESS`;data:any;};
That’s how to get rid of typos! Even better, let’s say we store all data in a global state object of type Data. We can derive all possible BackendRequest types from here. By using keyof Data, we get the string keys that make up the BackendRequest state.
typeData={user:User|null;order:Order|null;};typeRequestConstants=keyofData;typePending={state:`${Uppercase<RequestConstants>}_PENDING`;};typeErr={state:`${Uppercase<RequestConstants>}_ERROR`;message:string;};
This already works well for Pending and Err, but in the Success case we want to have the actual data type associated with "user" or "order".
A first idea would be to use index access to get the correct types for the data property from Data.
NonNullable<T> gets rid of null and undefined in a union type. With the compiler flag strictNullChecks on, both null and undefined are excluded from all types. This means that you need to manually add them if you have nullish states, and manually exclude them when you want to make sure that they don’t
typeSuccess={state:`${Uppercase<RequestConstants>}_SUCCESS`;data:NonNullable<Data[RequestConstants]>;};
But this would mean that data can be both User or Order for all back-end requests. And more if we add new ones. To avoid breaking the connection between the identifier and its associated data type, we map through all RequestConstants, create state objects, and then use index access of RequestConstants again to produce a union type.
typeSuccess={[KinRequestConstants]:{state:`${Uppercase<K>}_SUCCESS`;data:NonNullable<Data[K]>;};}[RequestConstants];
Success is now equal to the manually created union type.
typeSuccess={state:"USER_SUCCESS";data:User;}|{state:"ORDER_SUCCESS";data:Order;};