The TypeScript Gluten Behind NgRx createActionGroup
Mike Ryan
Posted on July 8, 2022
Creating strongly typed APIs in TypeScript relies on understanding advanced typing strategies. NgRx heavily depends on typing strategies such as string literal types, conditional types, and template literal types to create an API surface that encourages consumers to build strongly typed applications. Let’s take a look at an example API in the NgRx codebase to see how NgRx leverages these advanced typing techniques.
NgRx v13.2 introduces a new function for defining groups of actions:
const AuthApiActions = createActionGroup({
source: 'Auth API',
events: {
'Login Success': props<{ user: UserModel }>(),
'Login Failure': props<{ error: AuthError }>(),
},
});
The type of AuthApiActions
becomes an object containing methods for instantiating actions for each of the configured events:
this.store.dispatch(AuthApiActions.loginFailure({ error }));
What excites me the most about this new API is that it is 100% type-safe. How do we get from ‘Login Success’
to loginSuccess
on the function names though? TypeScript’s type literal type manipulation!
Going from ‘Event Name’ to eventName with TypeScript
NgRx Store’s codebase contains a utility type alias that converts 'Event Name'
to eventName
:
export type ActionName<EventName extends string> = Uncapitalize<
RemoveSpaces<Titlecase<Lowercase<Trim<EventName>>>>
>;
ActionName
is doing all of the heavy lifting to convert event names at the type level by:
- Starting with a string literal type (
EventName extends string
) - Trimming it
- Making it lowercase
- Titlecasing each segment of the string
- Removing the spaces between words
- Lowercasing the first letter
There is a lot going on here, so let’s break it down step-by-step!
1. String Literal Types
My experience with advanced types in TypeScript is that advanced types are extremely relevant when writing libraries and not as relevant in application code. One core concept of advanced typing in TypeScript that library authors often take heavy advantage of is string literal types. If you haven’t encountered them before, a string literal type is a string type but narrowed down to a specific string.
This will be a little easier to explain with examples. Let’s say we have a function that takes the name of a kind of bread and prints it to the console:
function bakeBread(kindOfBread: string) {
console.log(`🥖 Baking: ${kindOfBread}`);
}
There’s a problem with this function. I can pass this function any string and TypeScript won’t care:
bakeBread('Pie');
String literal types let us specify a concrete subtype of string to enforce correctness. For example, if we wanted to limit the type of kindOfBread
to "Wheat"
we could do this:
function bakeBread(kindOfBread: 'Wheat') {
console.log(`🥖 Baking: ${kindOfBread}`;
}
Now if we try to pass in a string that is not a kind of bread we get a type error:
bakeBread('Cake');
This produces the error:
Argument of type '"Cake"' is not assignable to parameter of type '"Wheat"'.(2345)
Obviously, there are more types of cake than just "Wheat"
though. By creating a union type of string literals, we can constrain the type of kindOfBread
to be the kinds of bread that our program is aware of:
type KindsOfBread =
| 'Wheat'
| 'White'
| 'Rye'
| 'Pumperknickel'
| 'Sourdough'
| 'Banana';
function bakeBread(kindOfBread: KindsOfBread) {
console.log(`🥖 Baking: ${kindOfBread}`;
}
Now we can call bakeBread
with a variety of valid bread types without error:
bakeBread('Rye');
bakeBread('Sourdough');
bakeBread('Banana');
And if we try to pass in a kind of bread that our program is not aware of we get a type error:
bakeBread('Pound Cake');
This results in:
Argument of type '"Pound Cake"' is not assignable to parameter of type 'KindsOfBread'.(2345)
2. Trimming String Literal Types
NgRx’s ActionName
operates on string literal types. From here, it starts applying advanced typing on string literal types to coerce a string literal type of "Event Name"
into "eventName"
.
The first step is to trim the string literal types, or, in other words, remove any surrounding whitespace. That way, if the developer passes in a string like " Event Name"
we don’t produce a function whose name is eventName
.
To strip the whitespace around a string literal type, we are going to have to use conditional types. A conditional type is a type that checks if a condition is true or not at the type level and can conditionally return a different type as a result of the condition.
Let’s take a look at example code!
interface SliceOfBread {
toast(): void;
}
interface SliceOfCake {
frost(): void;
}
interface Bread {
slice(): SliceOfBread;
}
interface Cake {
slice(): SliceOfCake;
}
In this example, our program has interfaces for Cake
and Bread
both of which have a slice()
method for producing SliceOfCake
and SliceOfBread
respectively.
Now let’s write a function called slice
that takes an object of type Cake
or Bread
and returns the right result:
function slice(cakeOrBread: Cake | Bread): ??? {
return cakeOrBread.slice();
}
What type should we use for the return type of this function? Naively, we could use SliceOfCake | SliceOfBread
:
function slice(cakeOrBread: Cake | Bread): SliceOfCake | SliceOfBread {
return cakeOrBread.slice();
}
This would require the consumer of slice
to inspect the return type to know if it got back a slice of cake or a slice of bread. For example, if we tried to toast a slice of bread we get back when passing in pumperknickel:
slice(pumperknickel).toast();
We get an error back from the TypeScript compiler:
Property 'toast' does not exist on type 'SliceOfCake | SliceOfBread'.
Property 'toast' does not exist on type 'SliceOfCake'.(2339)
We could use function overloads to write slice
in a way that works correctly:
function slice(cake: Cake): SliceOfCake;
function slice(bread: Bread): SliceOfBread;
function slice(cakeOrBread: Cake | Bread): SliceOfCake | SliceOfBread {
return cakeOrBread.slice();
}
This removes the type errors and all of the types are inferred correctly. However, we can shorten this by leveraging conditional types. Let’s write a type alias that takes in a type T
and converts it into a SliceOfCake
if T
is Cake
or never
if T
is not Cake
:
type Slice<T> = T extends Cake ? SliceOfCake : never;
As you can see, conditional types borrow their syntax from ternary expressions in JavaScipt. Now if we pass in Cake
(or any subtype of Cake
) to Slice
we get back SliceOfCake
:
type Result = Slice<Cake> // Returns "SliceOfCake"
We can nest conditional expressions to make Slice
aware of both Bread
and Cake
:
type Slice<V> = V extends Cake
? SliceOfCake
: V extends Bread
? SliceOfBread
: never;
Now if we pass in Bread
or Cake
to Slice
get back SliceOfBread
or SliceOfCake
, respectively:
type Result1 = Slice<Bread> // "SliceOfBread"
type Result2 = Slice<Cake> // "SliceOfCake"
type Result3 = Slice<Cereal> // "never"
We can use conditional types in combination with string literal types to start producing functions with powerful type inference.
Let’s take our KindsOfBread
type from earlier and compliment it with a KindsOfCake
type to rewrite Slice
, only this time Slice
will take in a string literal type and produce either SliceOfBread
if we pass in a kind of bread or SliceOfCake
if we pass in a kind of cake:
type KindsOfBread =
| 'Wheat'
| 'White'
| 'Rye'
| 'Pumperknickel'
| 'Sourdough'
| 'Banana';
type KindsOfCake =
| 'Vanilla'
| 'Chocolate'
| 'Strawberry'
| 'Pound'
| 'Coffee';
type Slice<T> = T extends KindsOfBread
? SliceOfBread
: T extends KindsOfCake
? SliceOfCake
: never;
Let’s see what we get back now:
type Result1 = Slice<'Banana'> // "SliceOfBread"
type Result2 = Slice<'Vanilla'> // "SliceOfCake"
type Result3 = Slice<'Tuna'> // "never"
This works great, but there’s still an aesthetic problem with the code. No one writes out “Vanilla” or “Banana” and expects you to know they are talking about cakes and breads. Aesthetically, this code would be more pleasing if we wrote it out like this:
type Result1 = Slice<'Banana Bread'>;
type Result2 = Slice<'Vanilla Cake'>;
type Result3 = Slice<'Tuna Fish'>;
How can we extract the first part of the string literal types (the kind) to figure out what we are returning? In TypeScript, expressions passed to conditional types can use inference to infer new types.
To take advantage of this, let’s write out a type for the categories of foods our application supports:
type Foods = 'Bread' | 'Cake' | 'Fish';
Now let’s write a type that extracts the kind modifier from a type literal like "Tuna Fish"
:
type ExtractKind<V> = V extends `${infer Kind} ${Foods}`
? Kind
: never;
What’s this doing? We are testing if the type parameter V
is a string literal type in the format of ${Kind} ${Foods}
. For example, if we pass in "Tuna Fish"
we will get back "Tuna"
as the inferred type Kind
. If we pass in just "Tuna"
we will get back never
since the string literal type "Tuna"
is not in the format of "Tuna Fish"
. Using this, we can now improve the aesthetics of Slice
:
type Slice<T, V = ExtractKind<T>> = V extends KindsOfBread
? SliceOfBread
: V extends KindsOfCake
? SliceOfCake
: never;
type Result1 = Slice<'Banana Bread'> // "SliceOfBread"
type Result2 = Slice<'Vanilla Cake'> // "SliceOfCake"
type Result3 = Slice<'Tuna Fish'> // "never"
NgRx’s ActionName
needs to trim string literal types before doing any further conversion. It’s trimming strings by applying the exact same string inference trick that our ExtractKind
utility is using by recursively inferring the string surrounded by whitespace:
type Trim<T extends string> = T extends ` ${infer R}`
? Trim<R>
: T extends `${infer R} `
? Trim<R>
: T;
If you pass in " Banana Cake "
to Trim
you get back "Banana Cake"
. Powerful TypeScript magic!
3. Lowercasing String Literal Types
With our bread sliced and our strings trimmed, we are ready to move on to the next bit of TypeScript behind ActionName
: lowercasing string literal types!
How could we get from "Banana Bread"
to "banana bread"
? We could write out a very long and complex conditional type that maps each uppercase character into a lowercase character. Thankfully, however, TypeScript gives us a Lowercase
utility type out-of-the-box. 🙂
type Result = Lowercase<"Banana Bread"> // "banana bread"
Lowercasing? Easy! TypeScript ships with four utility types for manipulating string literal types:
-
Lowercase<"Banana Bread">
to produce"banana bread"
-
Uppercase<"Banana Bread">
to produce"BANANA BREAD"
-
Capitalize<"banana">
to produce"Banana"
-
Uncapitalize<"BANANA">
to produce"bANANA"
4. Titlecasing String Literal Types
TypeScript ships with utility types to lowercase, uppercase, capitalize, and uncapitalize string literal types. It does not include string literal types to do more advanced string manipulation.
For NgRx, we ultimately want to convert a string of words describing an event into a camelCased function name. To get there, we need to first convert the words into title case. In other words, go from "banana bread"
to "Banana Bread"
.
Before we build a titlecasing type utility, we need to explore template literal types. A template literal type is a supercharged string literal type that uses string interpolation syntax to create new string literal types. In our program, we have a KindsOfBread
type that is a union of all of the kinds of breads our program is aware of. We could expand this into a type that includes the word "Bread"
by using a template literal type:
type Bread = `${KindsOfBread} Bread`;
This would be the same as writing:
type Bread =
| "Wheat Bread"
| "White Bread"
| "Rye Bread"
| "Pumperknickel Bread"
| "Sourdough Bread"
| "Banana Bread";
Using template literal types, we can strengthen the clarity of our Slice
type:
type Bread = `${KindsOfBread} Bread`;
type Cake = `${KindsOfCake} Cake`;
type Slice<T extends Bread | Cake, V = ExtractKind<T>> = V extends KindsOfBread
? SliceOfBread
? V extends KindsOfCake
? SliceOfCake
: never;
Our types continue to infer correctly:
type Result1 = Slice<'Banana Bread'> // SliceOfBread
type Result2 = Slice<'Coffee Cake'> // SliceOfCake
And now if we try to pass in a food item that is not bread or cake we get a better error:
Type '"Tuna Fish"' does not satisfy the constraint '"Wheat Bread" | "White Bread" | "Rye Bread" | "Pumperknickel Bread" | "Sourdough Bread" | "Banana Bread" | "Vanilla Cake" | "Chocolate Cake" | "Strawberry Cake" | "Pound Cake" | "Coffee Cake"'.
Template literal types let us expand unions of string literal types into new unions of string literals. We can build a titlecasing type utility using TypeScript’s built-in string literal type utilities, conditional types, and template literal types:
type Titlecase<T extends string> = T extends `${infer First} ${infer Rest}`
? `${Capitalize<First>} ${Titlecase<Rest>}`
: Capitalize<T>;
Our Titlecase
utility is doing the following:
- Splitting up a string like
"banana nut bread"
into two types,First
which is"banana"
andRest
which is"nut bread"
- It passes
First
toCapitalize
andRest
toTitlecase
for recursive processing - Once it gets to the very last word in the string literal type (in this case
"bread"
) it passes it toCapitalize
Now we can convert any string literal type into a titlecased string literal type:
type Result = Titlecase<"banana nut bread"> // "Banana Nut Bread"
5. Removing Spaces Between Words
We can convert a string literal type that uses mixed casing with padded whitespace into a trimmed, titlecased string using the builtin Lowercase
and our handwritten Trim
and Titlecase
type aliases:
type R = Titlecase<Lowercase<Trim<" banana NUT bread ">>> // "Banana Nut Bread"
We are still trying to get this to be in the form of "bananaNutBread"
meaning we have to strip the spaces between words. Thankfully, we don’t need to learn any new tricks. We have everything we need with conditional types and template literal types:
type RemoveSpaces<T extends string> = T extends `${infer First} ${infer Rest}`
? `${First}${RemoveSpaces<Rest>}`
: T;
This is very similar to Titlecase
, only this time we are not doing any additional string manipulation. All this type utility does is take a string literal type in the form of "Banana Nut Bread"
and convert it into "BananaNutBread"
.
6. Lowercasing the First Letter
We are so close now to having the ability to go from " banana NUT bread "
to "bananaNutBread"
. All we are missing is a way to uncapitalize the first letter. And if you recall, TypeScript ships with a type utility to do just that! Now we can write out our full ActionName
utility using the built-in Lowercase
and Uncapitalize
in combination with our Trim
, Titlecase
, and RemoveSpaces
type utilities:
type ActionName<T extends string> =
Uncapitalize<RemoveSpace<Titlecase<Lowercase<Trim<T>>>>>
🥳🎉🥖
Conclusion
NgRx’s createActionGroup
relies on advanced TypeScript typing to convert the names of events into strongly-typed function names. It is able to cast from "Event Name"
to "eventName"
through a combination of string literal types, conditional types, and template literal types. I want to give a huge shout out to Marko Stanimirović for turning this concept into a fully functioning and well tested NgRx feature. Check out the full source code if you want to see the rest of the type magic going on under-the-hood of createActionGroup
.
Posted on July 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.