On a recent client engagement fellow agent Josh and I discovered a useful TypeScript feature that I hadn’t encountered before.

The team we were working with had a utility function that had not yet been given type expectations due to its complex return type. This function could return a different type based on its input type. TypeScript provides a feature for solving this kind of complex return type called conditional types. Learning how to define a conditional return type allowed us to inform TypeScript about the condition, resulting in better type awareness in all code that called the function.

A function with two return types

Our client’s TypeScript codebase had a utility function for user permissions called getPermissions. This function had been written to support passing the name of a single permission or multiple permissions. In response the function would return a single boolean or an array of booleans. Returning an array allowed developers to destructure the result in the order the permissions were passed to the function.

// getPermissions could be passed a single permission
const hasReadPermission = getPermissions("user:read");

// or multiple permissions
const [hasRead, hasWrite] = getPermissions(["user:read", "user:write"]);

The team had made the decision to turn on TypeScript’s noImplicitAny rule (a great TypeScript discussion for another time). This meant the getPermissions function needed to be given proper type expectations.

Adding type expectations

getPermissions relied on fetching server data and utilized a third-party library. I’d like to strip those details away to get a closer look at the TypeScript story. A very simplified example of this function for getting user permissions might look like the following code:

const grantedPermissions = [/* assume there are some permissions here */];
function getPermissions(permissionKey: string | string[]) {
  if (Array.isArray(permissionKey)) {
    return permissionKey.map((key) => grantedPermissions.includes(key));
  }
  return grantedPermissions.includes(permissionKey);
}

The only explicit TypeScript type annotation here is that the function must accept one parameter that is either a string or string[] (array of strings). TypeScript is able to infer the return type based on the values we return: boolean | boolean[]. Note that boolean | boolean[] is a union type and will be the type given to any variable that is assigned the result of getPermissions.

The union return type creates a decision tree that every caller of the function is forced to resolve. Each time getPermissions is called, logic will be needed to decide if the result is a single boolean or a boolean[]. Even though you, the developer, can understand that by passing an array, the function should return an array, the type system does not have enough information.

// the result will need to be casted
const hasPermissions = getPermissions(["secret:read", "user:write"]);
const [perm1, perm2] = hasPermissions as boolean[];

// or an if check is needed to verify the type before treating as an array
if (Array.isArray(hasPermissions)) {
  const [perm1, perm2] = hasPermissions;
}

This is a utility function and, in the real-world example, it is called dozens of times throughout the codebase with the expectation that it will be used again frequently. A good thing for us as developers to prioritize here is to make the function as easy-to-use as possible.

Solving without conditional types

To solve this, you don’t necessarily need to know about deeper TypeScript features like conditional types. One possible solution simply involves creating two functions: one for a single permission check and another for checking multiple permissions. This could be a perfectly viable solution.

function getPermission(permissionKey: string) { 
  return grantedPermissions.includes(permissionKey);
}

function getPermissions(permissionKeys: string[]): boolean[] {
  return permissionKeys.map((key) => grantedPermissions.includes(key));
}

This clears up the need for the caller code to make a decision about the return type. However, it leaves the developer of the calling code with the responsibility of knowing about the two different functions. In our case we decided there was value in having a single function that could handle both cases. A quick look through TypeScript documentation led us to a great solution.

Adding conditional type expectations

TypeScript provides many useful tools for refining type expectations and constraints. One that seemed perfectly fit for this situation is called conditional types. TypeScript conditional types can be used in combination with TypeScript generics to define a type that depends on a condition. In this case this allowed us to configure a return type that changes based on the input type.

This is an example of what the function definition could look like with conditional types:

function getPermissions<KeyType extends string | string[]>(
  permissionKey: KeyType
): KeyType extends string ? boolean : boolean[] {/* ... */}

In this example the generic type KeyType captures the type of the permissionKey parameter when then function is called. The return type is a conditional type defined as boolean if the KeyType is a string and a boolean[] if the KeyType is a string[].

I sometimes find that giving types a name can make their purpose more clear. The following code is the same function definition with named types.

type PermissionsKey = string | string[];
type PermissionsResponse<Key> = Key extends string ? boolean : boolean[];

function getPermissions<Key extends PermissionsKey>(permissionKey: Key): PermissionsResponse<Key> {/* ... */}

Using conditional types in this function improves the type awareness of all calling code. Here’s a complete example of the function to help to make the benefits clear.

type PermissionsKey = string | string[];
type PermissionsResponse<Key> = Key extends string ? boolean : boolean[];

const grantedPermissions = [/* assume there are some permissions here */];
function getPermissions<Key extends PermissionsKey>(
  permissionKey: Key
): PermissionsResponse<Key> {
  if (Array.isArray(permissionKey)) {
    return permissionKey.map((key) => grantedPermissions.includes(key)) as PermissionsResponse<Key>;
  }
  return grantedPermissions.includes(permissionKey) as PermissionsResponse<Key>;
}

Callers of this function will be able to infer the return type based on the permissionKey parameter they pass.

/* Calling hasPermissions with a string */
const hasReadPermission = getPermissions("user:read");
// The editor and compiler know hasReadPermission is type boolean
/* Calling hasPermissions with a string array */
const hasPermissions = getPermissions(["secret:read", "user:write"]);
// The editor and compiler know hasPermission is type boolean[]

// and will consider the following statements valid
hasPermissions.length

hasPermissions[0]

const [hasRead, hasWrite] = getPermissions(["secret:read", "user:write"]);

Just one of the benefits of TypeScript

This highlights one of the things I find to be super useful when using TypeScript. My productivity is boosted when my editor is able to give quick feedback regarding the types of my variables and functions.

This simplified example revealed a TypeScript limitation we did not encounter in the real function due to some additional abstraction in the original code. Ideally I would like to show an example that does not require casting the return types with as.

// IDEAL EXAMPLE; BUT DOES NOT COMPILE
const grantedPermissions = [/* assume there are some permissions here */];
function getPermissions<Key extends PermissionsKey>(
  permissionKey: Key
): PermissionsResponse<Key> {
  if (Array.isArray(permissionKey)) {
    return permissionKey.map((key) => grantedPermissions.includes(key));
  }
  return grantedPermissions.includes(permissionKey);
}

Unfortunately, in this case the Array.isArray check is not sufficient to narrow TypeScript’s understanding of the return type. If we discover a better solution in the future we will update this post.

Ultimately using a conditional type definition gave our team the benefits of accurate type expectations and a function that was easy to re-use.

Have questions, or want to talk about this post with other developers? Join the conversation in the N.E.A.T. community.

Lewis M Sparlin

Person An icon of a human figure Status
Double Agent
Hash An icon of a hash sign Code Name
Agent 00125
Location An icon of a map marker Location
Joplin, MO, USA