DEV Community

Cover image for The TypeScript Gluten Behind NgRx createActionGroup
Mike Ryan
Mike Ryan

Posted on • Originally published at liveloveapp.com

The TypeScript Gluten Behind NgRx createActionGroup

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 }>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

The type of AuthApiActions becomes an object containing methods for instantiating actions for each of the configured events:

this.store.dispatch(AuthApiActions.loginFailure({ error }));
Enter fullscreen mode Exit fullscreen mode

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>>>>
>;
Enter fullscreen mode Exit fullscreen mode

ActionName is doing all of the heavy lifting to convert event names at the type level by:

  1. Starting with a string literal type (EventName extends string)
  2. Trimming it
  3. Making it lowercase
  4. Titlecasing each segment of the string
  5. Removing the spaces between words
  6. 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}`);
}
Enter fullscreen mode Exit fullscreen mode

There’s a problem with this function. I can pass this function any string and TypeScript won’t care:

bakeBread('Pie');
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

Now if we try to pass in a string that is not a kind of bread we get a type error:

bakeBread('Cake');
Enter fullscreen mode Exit fullscreen mode

This produces the error:

Argument of type '"Cake"' is not assignable to parameter of type '"Wheat"'.(2345)
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

Now we can call bakeBread with a variety of valid bread types without error:

bakeBread('Rye');
bakeBread('Sourdough');
bakeBread('Banana');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

This results in:

Argument of type '"Pound Cake"' is not assignable to parameter of type 'KindsOfBread'.(2345)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Let’s see what we get back now:

type Result1 = Slice<'Banana'> // "SliceOfBread"
type Result2 = Slice<'Vanilla'> // "SliceOfCake"
type Result3 = Slice<'Tuna'> // "never"
Enter fullscreen mode Exit fullscreen mode

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'>;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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`;
Enter fullscreen mode Exit fullscreen mode

This would be the same as writing:

type Bread =
  | "Wheat Bread"
  | "White Bread"
  | "Rye Bread"
  | "Pumperknickel Bread"
  | "Sourdough Bread"
  | "Banana Bread";
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Our types continue to infer correctly:

type Result1 = Slice<'Banana Bread'> // SliceOfBread
type Result2 = Slice<'Coffee Cake'> // SliceOfCake
Enter fullscreen mode Exit fullscreen mode

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"'.
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

Our Titlecase utility is doing the following:

  1. Splitting up a string like "banana nut bread" into two types, First which is "banana" and Rest which is "nut bread"
  2. It passes First to Capitalize and Rest to Titlecase for recursive processing
  3. Once it gets to the very last word in the string literal type (in this case "bread") it passes it to Capitalize

Now we can convert any string literal type into a titlecased string literal type:

type Result = Titlecase<"banana nut bread"> // "Banana Nut Bread"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>>>>>
Enter fullscreen mode Exit fullscreen mode

🥳🎉🥖

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.

Top comments (0)