How to write a function with a conditionally required parameter

Functions are the bread and butter of Javascript. It leans more strongly into a functional programming paradigm than any modern language today.

But functions can be weird!

Then, on top of that, let’s add the added layer of complexity that is Typescript. How do we start defining types for weird functions?

What if I wanted a function to take in multiple arguments, but SOME of the arguments dependent on whether the others are present or not?

Sure, defining the function can be done, and honestly is not that big of a deal.

But what if I want types for it?

That may be a bit of stinger!

So let’s take a look at how that can be achieved 🙂

Conditional type implementation: Function overloading vs clever types

There are two ways we can go about solving this problem:

  1. Use function overloading
  2. Use a handy ‘hack’ to define a type

While I am a fan of the first solution, it can definitely be done using a clever type that relies on discriminated unions.. But more on that later!

Let’s first take a look at how this can be achieved using function overloading.

Function Overloading

Let’s use an example I’m familiar with: User roles.

In our hypothetical example a user can have one of three roles: standard, deptAdmin or superAdmin.

In our thought-out example, the deptAdmin role is dependent on another attribute, namely the departmentId, for which that user is an admin.

It stands to reason that a deptAdmin, therefore, cannot be a deptAdmin with no departmentId!

Using function overloading we could set up the scenario as such:

type UserRoles = "standard" | "deptAdmin" | "superAdmin";

// Overload with departmentId
function setUserRole(
    role: Extract<UserRoles, "deptAdmin">, 
    departmentId: string
): void;

// Non error overload
function setUserRole(
    role: Exclude<States, "deptAdmin"> 
): void; 

// Full implementation
function setUserRole<Role extends UserRoles>( 
    role: Role, 
    departmentId?: string 
): void { 
    // implementation 
}

setUserRole("standard"); // should pass
setUserRole("standard", "12345"); // should fail
setUserRole("superAdmin"); // should pass
setUserRole("superAdmin", "anything"); // should fail
setUserRole("deptAdmin"); // should fail 
setUserRole("deptAdmin", '12345'); // should pass

While typing this out may seem a bit verbose, this use of function overloading means were defining two different “versions” of the function that, with the use of Typescript, is able to compile into Javascript the expected behaviour we want out of this.

Function overloading using Typescript can be extremely powerful because it allows flexibility for cases that isn’t available straight out of the box with pure Javascript, although much can still be achieved using it.

This example provides the advantage of types, giving our editor all the advantages of autocomplete, error feedback and so on.

However, as I am aware this example may be too verbose for some use cases, or even for the liking of some, I’ve brought another one!

Clever type

For the second I have something a lot more concise that can be easily implemented.

Let’s take a look:

type Roles =
  | [role: 'standard']
  | [role: 'superAdmin']
  | [role: 'deptAdmin', departmentId: string]

function setUserRole(...args: Roles): void

As anyone with eyes can immediately tell: Damn that’s smooth!

Yes, the above example is extremely concise and does bring the same benefit of auto completion.

In fact, for this use case it’s actually so good, that we can entirely omit the function itself!

How is that possible?!

This is why Typescript can be quite clever. The type “Roles” in this example is a discriminated union case that says Roles is either an array of properties role which can have the value standard OR it is an array of properties role which can have the value superAdmin OR it is an array of properties role and departmentId which can have the values deptAdmin or any string, respectively.

So, that means whenever we pass any object definition the type of Roles we already know what it’s going to look like. We could use that type in a function definition, like in the example above, but it seems almost easier to do it directly when defining the object.

Because at the end of the day, Typescript is all about providing a better Developer Experience, by providing a shorter feedback loop in which we get errors directly in our editor.

Hope you learned a trick or two! Feel free to copy either of the snippets here 🙂

Want to learn more nifty tricks?

I blog every once in a while, about once a week, and try to write helpful and purposeful content, that help you go from Typescript zero, to Typescript hero.

I’d be honored to have you subscribe to my newsletter, by filling out the form below 🙏

Don’t worry – I won’t spam you!