Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Software tooling & tips

A quick guide to TypeScript conditional types

Discover how to leverage TypeScript conditional types to enhance your code's type accuracy and reusability. Get practical tips and elevate your TypeScript skills.
Lewis M Sparlin
|
March 27, 2024
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

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 etPermissions. 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.

Related Insights

🔗
A lesson in type safety with TypeScript, Remix and Prisma
🔗
Get safer TypeScript code with Zod: a practical guide

Explore our insights

See all insights
Developers
Developers
Developers
You’re holding it wrong! The double loop model for agentic coding

Joé Dupuis has noticed an influx of videos and blog posts about the "correct" way of working with AI agents. Joé thinks most of it is bad advice, and has a better approach he wants to show you.

by
Joé Dupuis
Leadership
Leadership
Leadership
Don't play it safe: Improve your continuous discovery process to reduce risk

We often front-load discovery to feel confident before building—but that’s not real agility. This post explores how continuous learning reduces risk better than perfect plans ever could.

by
Doc Norton
Leadership
Leadership
Leadership
How an early-stage startup engineering team improved the bottom line fast

A fast-growing startup was burning cash faster than it could scale. Here’s how smart engineering decisions helped them improve the bottom line.

by
Jonathon Baugh
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.