K O logo
TypeScriptSoftware Engineering

The Value of Complex Types

Author

Kalle

Date Published

This AI-generated image shows a tech-focused workspace centered around TypeScript programming. The desk features a large monitor displaying code, a laptop with diagrams, books, and notes on TypeScript concepts. The wall is adorned with illuminated diagrams related to TypeScript, and the scene includes a desk lamp, plant, and a coffee mug, creating a productive and focused atmosphere.

I still remember the first time I learned about more advanced types. It was at university, and the course used generic types in Java to teach us about the complex parts of data structures. Back then, it was overwhelming, and it was hard to see the value of it. Especially the notation using single letters like T or K didn't make it easier to understand. This is, of course, typical for academia, and in hindsight, I fully get that the abstract concepts need abstract variables. But I could see this as a reason why many people are scared of complex types.

Today, I see more and more generic types, especially in TypeScript, with descriptive names, which makes it easier to understand. Even on the abstract level, it's now more common to see Type instead of T and Key instead of K. But still, the complexity of the type system is there, and even though the barrier of entry got lower, the complexity can be seen as an overhead during development. So why should we care?

The Benefits of a Strong Type System

In my opinion, the benefits of a strong type system can be broken down to one simple point: Safety.

A type system allows the compiler to check your code, and with the rule set defined in the type system, your computer can point out many bugs while you are still writing the code. This perfectly fits the "fail fast" principle, which is a key concept in software development. You want to see errors as soon as possible, so the immediate feedback loop in your IDE is as best as it can be. This can also be seen as a form of unit testing, e.g., all function inputs are tested if they are of the correct type, and you can be sure that no unexpected null values are passed down.

For web development, this was not available for a long time. JavaScript started as a very simple language, with the goal to be directly executable in the browser. Coming from compiled languages like Java, C#, or even C can be quite difficult. Suddenly the IDE feels more like a text editor, and you are on your own to remember all the function signatures and the types of the variables. Luckily, TypeScript is available, and with the current adoption rate, it is the de facto industry standard.

The Rise of TypeScript

The first public release of TypeScript was in 2012 (version 0.8), and for the first few years, it was a niche project. Back then, it was also not foreseeable that Microsoft would become so important for the web development community. My first contact with the language was in 2015, with version 1.4. I was immediately hooked because finally, my computer was able to help me again with my code. Even though WebStorm was a great IDE for JavaScript, coding always felt dangerous, especially when refactoring in larger codebases.

In 2015, Facebook released Flow, which tackled the same problem but with a different approach. Flow didn't rely so much on annotations and tried to infer the types automatically. However, from my perspective, TypeScript was still a lot easier to use because of the tooling and the community.

I think the tooling was also the main reason why TypeScript became so popular and is now the industry standard for web development. With the release of the Language Server Protocol (LSP) in 2016, the integration of TypeScript in all major IDEs was possible, paving the way for widespread adoption.

Over the years, it was interesting to see how the community adopted TypeScript. In the beginning, there were many loud voices against the "boilerplate," but at some point, the same people started to tweet about always using the "strict" config. The last domain regularly complaining about TypeScript is the web libraries. A common talking point is "use TypeScript for your app, but not for library code." Perhaps it's still possible to improve the type system to make it easier for dynamic use cases, but I think dynamic libraries are complex by definition, so it won't ever be easy to type them.

Example: Type Safe Navigation in React Native

Recently I had the problem that the navigation in a react native app was not as stable as I wanted it to be. The navigation was done with the react-navigation library, which is a great library, but not very opinionated about how to use it. Each screen can define the parameters it expects, but it's up to the developer to keep the types in sync with the actual implementation. My solution was to create a function to generate a static navigation config with a navigate function that takes a path and the necessary parameters. Another function generated from the static config provides access to the screen parameters based on the path. This creates a single source of truth for the navigation and provides feedback about broken navigations at compile time.

The basis for this solution is a tree structure to define the screens and their parameters:

1interface ParamsDefinition {
2 [key: string]: {
3 type: "string" | "number" | "boolean";
4 optional: boolean;
5 };
6}
7
8interface RouteConfig {
9 children?: NavTree;
10 params?: ParamsDefinition;
11}
12
13interface NavTree {
14 [key: string]: RouteConfig;
15}

This allows a URL structure with with screens on the same level and each screen can have nested screens as children. Each screen can define the query parameters it expects. Parameters can be optional or required and can be of type stringnumber, or boolean. URL parameters are defined as key in the tree structure. An example tree could look like this:

1const navTree = {
2 home: {
3 children: {
4 profile: {
5 params: {
6 userId: { type: "string", optional: false },
7 },
8 },
9 },
10 },
11 products: {
12 children: {
13 ":productId": {},
14 },
15 },
16} satisfies NavTree;

These type definitions are pretty simple so far, but with the next helper type the complexity increases. The goal is to extract the route configuration from the tree and a URL path:

1type RouteConfigForUrl<
2 Tree extends NavTree,
3 Path extends string,
4> = Path extends `/${infer Segment}/${infer Rest}`
5 ? Segment extends keyof Tree
6 ? Tree[Segment]["children"] extends NavTree
7 ? RouteConfigForUrl<Tree[Segment]["children"], `/${Rest}`>
8 : never
9 : never
10 : Path extends `/${infer Segment}`
11 ? Segment extends keyof Tree
12 ? Tree[Segment]
13 : never
14 : never;

This type is not used directly, but it enabled targeting every screen config in the tree with a URL path. The following example shows how to get the config for the profile screen:

1type ProfileConfig = RouteConfigForUrl<Tree, "/home/profile">;
2
3// ProfileConfig = {
4// params: {
5// userId: { type: "string"; optional: false; };
6// };
7// }

With the route config for a URL path, the next step is to extract the parameters and their types:

1type OptionalKeys<Parameters extends ParamsDefinition> = {
2 [Key in keyof Parameters]: Parameters[Key]["optional"] extends true
3 ? Key
4 : never;
5}[keyof Parameters];
6
7type ParamDefinitionType<
8 Parameters extends ParamsDefinition,
9 Key extends keyof Parameters,
10> = Parameters[Key]["type"] extends "string"
11 ? string
12 : Parameters[Key]["type"] extends "number"
13 ? number
14 : Parameters[Key]["type"] extends "boolean"
15 ? boolean
16 : never;
17
18type RouteConfigQueryParams<Parameters extends ParamsDefinition> = {
19 [Key in OptionalKeys<Parameters>]?: ParamDefinitionType<Parameters, Key>;
20} & {
21 [Key in Exclude<
22 keyof Parameters,
23 OptionalKeys<Parameters>
24 >]: ParamDefinitionType<Parameters, Key>;
25};
26
27type RouteConfigParamsForUrl<
28 Tree extends NavTree,
29 Path extends string,
30> = RouteConfigForUrl<Tree, Path>["params"] extends ParamsDefinition
31 ? RouteConfigQueryParams<RouteConfigForUrl<Tree, Path>["params"]>
32 : never;

Similar to the RouteConfigForUrl type the new definitions allow to target the parameters of a URL:

1type ProfileParams = RouteConfigParamsForUrl<Tree, "/home/profile">;
2// ProfileParams = {
3// userId: string;
4// }
5type HomeParams = RouteConfigParamsForUrl<Tree, "/home">;
6// HomeParams = never

Additionally to the query parameters the navigation tree can also define path parameters:

1type ParamNameForPathSegment<T extends string> = T extends `:${infer ParamKey}`
2 ? ParamKey
3 : never;
4
5type PathParamNames<Path extends string> =
6 Path extends `/${infer Segment}/${infer Rest}`
7 ? ParamNameForPathSegment<Segment> | PathParamNames<`/${Rest}`>
8 : Path extends `/${infer Segment}`
9 ? ParamNameForPathSegment<Segment>
10 : never;
11
12type RouteParamValue = string;
13type RouteParams<ParamKeys extends string> = Record<ParamKeys, RouteParamValue>;
14type PathParams<Path extends string> = RouteParams<PathParamNames<Path>>;

These helper types are independent from the navigation tree and just provide all segments in a URL starting with :.

1type ProductPathParameters = PathParams<"/products/:productId">;
2// ProductPathParameters = {
3// productId: string;
4// }

The last part of the ground work is one helper type to extract all possible URL paths from the navigation tree:

1type RouteConfigUrl<Tree extends NavTree> = {
2 [Key in keyof Tree & (string | number)]: Tree[Key]["children"] extends NavTree
3 ? `/${Key}` | `/${Key}${RouteConfigUrl<Tree[Key]["children"]>}`
4 : `/${Key}`;
5}[keyof Tree & (string | number)];

This generates a union type of all possible URL paths in the navigation tree:

1type Routes = RouteConfigUrl<Tree>;
2// Routes = "/home" | "/home/profile" | "/products" | "/products/:productId"

The last step is now to generate the type definition for all possible app links, so the combination of all possible URLs with their parameters:

1type RouteConfigLink<
2 Path extends string,
3 Tree extends NavTree,
4> = Path extends `/${infer Url}`
5 ?
6 | PathParamNames<`/${Url}`>
7 | RouteConfigParamsForUrl<Tree, `/${Url}`> extends never
8 ? {
9 path: `/${Url}`;
10 params?: never;
11 }
12 : RouteConfigParamsForUrl<Tree, `/${Url}`> extends never
13 ? {
14 path: `/${Url}`;
15 params: Prettify<PathParams<`/${Url}`>>;
16 }
17 : PathParamNames<`/${Url}`> extends never
18 ? RequiredKeys<RouteConfigParamsForUrl<Tree, `/${Url}`>> extends never
19 ? {
20 path: `/${Url}`;
21 params?: Prettify<RouteConfigParamsForUrl<Tree, `/${Url}`>>;
22 }
23 : {
24 path: `/${Url}`;
25 params: Prettify<RouteConfigParamsForUrl<Tree, `/${Url}`>>;
26 }
27 : {
28 path: `/${Url}`;
29 params: Prettify<
30 PathParams<`/${Url}`> & RouteConfigParamsForUrl<Tree, `/${Url}`>
31 >;
32 }
33 : never;

This allows the definition of the AppLink type, which can be used for type save navigation:

1type AppLink = RouteConfigLink<Routes, Tree>;
2// AppLink = {
3// path: "/home";
4// params?: never;
5// } | {
6// path: "/home/profile";
7// params: {
8// userId: string;
9// };
10// } | {
11// path: "/products";
12// params?: never;
13// } | {
14// path: "/products/:productId";
15// params: {
16// productId: string;
17// };
18// }

If you want to see the full example in action, you can use the TypeScript playground

Conclusion

The example above shows how complex it can get, but also how powerful the type system in TypeScript is. Coming up with this solution took a lot of time, but as the navigation is a core part for the app the gain in safety was worth it.

In general it is very important to find the right balance between premature optimization and too much technical dept. In the concrete example I introduced the type safe navigation after the app already had more than 70 screens.

For a project like this be sure of the behavior you want to describe, and don't spend too much time to over-engineer the solution. But also take your time to learn what TypeScript can do for you, learning the ins and outs of the advanced types opens up a whole new solution space for your projects.

Mailing List

If you want to receive updates on new posts, leave your email below.