Dynamic navigation parameters in React Navigation using Typescript

Apr 19, 2023

Tómas Pálsson

In our previous blog post, we whipped up a scrumptious concoction of asynchronous navigation logic. But it lacked the secret sauce – rock-solid type safety!

A Tale of Two Typing systems: Discovering the Power of Typescript's Flexibility

Back in the day when I was a young and innocent developer I grew up in the nominally typed neighbourhood of Java and C#. I didn’t know the world outside those bounds so I just assumed most languages were similar in how they tackled type safety. I enjoyed working with types in C#, where you could use generic types and have the behaviour of your code change based on the type of the object that was passed in. The type system of C# felt very rigid and sturdy which can be a benefit but also a hindrance in some cases.

When I later on tried Typescript I discovered that there was a different approach, structural typings, which just seemed bananas to someone who grew up in the nominal neighbourhood. The sturdiness was there: you could implement some really strict type conditions, but at the same time it was flexible enough to allow for some amazing ideas. This flexibility came at a cost however as we are no longer able to query the type of an object at runtime, at least not without a second library

To demonstrate the differences we'll use two types, Ford and Volkswagen, both sporting identical fields.

In C#, we'd write it like this:

We'll write a short program to test whether C# sees the types as equal:

Notice that we can query the type of an object at runtime. This is one of the benefits of the nominally typed system of C# which is not possible in Typescript.

The same example in Typescript becomes:

So in Typescript’s eyes the types are identical while C# sees them as different

Typescript doesn't care about the who, what, or where of a type's creation – it only compares their structure to determine their equality. This is the crux of what makes types in Typescript more fluid which opens doors to creative solutions that nominally typed languages can only dream of. 

Dynamic function arguments

In C#, a method signature is set in stone, and the types of its arguments or return value remain unchangeable. Although a method can be overloaded, each overload still has explicit arguments.

But with Typescript's more fluid type system, you can whip up functions where the types AND number of arguments change based on the types OR values of other arguments. This dynamic function argument sorcery is masterfully explained by one of my favourite YouTubers, Matt Pocock, in this video:

Discriminated union types

Another powerful aspect of Typescript is discriminated union types. With this powerful tool, you can merge multiple types with at least one unique field, and Typescript will simplify their shared structure as much as possible. For instance:

When we then create a function which takes in an animal, the type changes immediately when we query the type field:

I highly recommend reading Typescript’s own Narrowing guide where they explain this in detail with great examples. I especially recommend reading discriminated unions which will play an important role in our solution.

Designing a Navigation System that Knows Exactly What You Need

We want to forge types for our navigation system which ensures that the input and output parameters of each screen adapt depending on which screen we're navigating to. Let’s use three of our screens from our previous blog post to illustrate the point:

Some screens demand specific information to function properly, and we want Typescript to wave a red flag if an input is missing in those cases. A simple solution might be to require an input for all screens and pass an empty object for screens that don't need inputs. But we're not here for mediocrity!

We want to go above and beyond and prohibit passing input to screens which don’t expect it.

Our end goal should be able to do this:

Bringing Our Magical Navigation System to Life

We'll begin by crafting a ScreenInput and ScreenOutput types, which take in a Screen enum value and produce the corresponding input and output types.

To achieve this, we create a discriminated union type, with the screen enum values acting as the discriminators. Each part of the union type will outline a single screen definition, specifying its input and output.

We can then utilize Typescript's Extract utility type to determine the ScreenDefinition type corresponding to the given screen:

We are now ready to create our ScreenInput type.

We have to watch out because screens which don’t have an input or output won’t be in the ScreenDefinition union type, like the screen SelectCurrency in our example. 

So, we first need to verify if that's the case by checking if the ScreenDefinition for the screen extends never. If it does, we simply emit undefined as the input type.

Now that we've confirmed we have a definition for the screen, we ask Typescript if it extends the structure { input: infer TOut }.

This accomplishes two things: If it doesn't match the structure, we can emit undefined. If it does, the infer keyword extracts the input field's type, and we emit that instead.

We then create a corresponding type for the output:

And now we've got a silky-smooth way of managing our inputs and outputs for each screen:

The Grand Finale: Unleashing Dynamic Function Arguments

We're making headway with our type safety, but the pièce de résistance – dynamic function arguments – still awaits.

The general strategy is to have the navigation functions accept a variable number of arguments using Rest parameters. We can then type the parameters using a tuple type that changes based on the provided Screen type, allowing us to control precisely how many arguments are permitted and required.

The tuple type kicks off by asking Typescript if the ScreenInput for the screen extends undefined. If it does, that means we're dealing with a screen that doesn't require any input at all, so we restrict the function to a single argument by creating a tuple type with only one element.

If it doesn't extend undefined, it does require input, and we force the function to accept exactly two arguments.

However, we soon realize that even when a screen doesn't need input, there are times when we want to pass input to modify how React Navigation manages the screen. For example, we might want to change the rendering of the initial route defined in a navigator.

To accommodate this, we create a new type, CommonScreenInput, to specify these fields.

We then tweak the navigation function type to allow either one or two arguments if no input is needed, resulting in:

And there you have it – our magical, super type-safe navigational system! Now we can strut around the office, dazzling every nominally-typed-thinking developer with our newfound powers.

Wrapping it up

This blog post delved into the captivating realm of type safety in Typescript, highlighting the dynamic and flexible nature of structural typing as opposed to the rigidity of nominal typing. We embarked on an ambitious quest to craft a type system for our navigation system that cleverly adapts input and output parameters based on the screen we're navigating to. By harnessing the power of discriminated union types, rest parameters, tuple types and dynamic function arguments, we devised a super type-safe navigational system which enforces clear and correct limitations on each screen, while also being simple to maintain as the complexity of the application increases.


Check out the full example on GitHub. The implementation in this blog can be found in the files AsyncNavigation.tsx and AsyncNavigationApp.tsx.