TypeScript Conditional Types for Type Safety (Without Assertions)


Summary

TypeScript conditional types are a useful tool to have in your tool belt for dealing with type safety across a large number of related types. In this short walkthrough, see how to use a property value to discriminate types without having to use the as assertion to “cast” the type.

Using conditional types this way can offer a limited facsimile of C# switch expressions with pattern matching (a very, very limited approximation 🤣).


Intro

TypeScript conditional types are one of the more interesting constructs that allow some flexibility in how to safely manage a large number of related types without running into the limitations with discriminated unions.

A perfect use case is for a system that is intended to receive webhooks where the payloads have a small set of common properties — one of which can be used to discriminate the message type — but each message type also carries a distinct payload.

It can be a bit gnarly to wrap your head around it the first time, but let’s see how we can use it to create type-safe call paths at build time.

💡 See the finished TypeScript Playground example


Basic Example

Define the Message Type

To start with, we’ll define the expected message types:

type MessageType = 'auth' | 'sync'

Here, simple strings are used to define the different types of messages that the system will consume. (I’m not sure if there is a hard limit as is the case with discriminated type unions, but I tested up to 40 without issue.)

Define the Base or Common Message

Next define a base or common message structure that exposes a generic parameter which will be the type of the discriminating property (MessageType in this case):

type MessageBase<T extends MessageType> = {
  type: T, // 👈 👆 The discriminator
  id: string,
  payload: string
}

type AuthMessage = {
  identity: string
} & MessageBase<'auth'> // 👈 pick a specific value

type SyncMessage = {
  timestamp: string
  topic: string
} & MessageBase<'sync'> // 👈 pick a specific value

Define the Conditional Type

With the types in place, then it is possible to move on to defining the conditional type which will resolve into a specific type based on the MessageType:

type Message<T extends MessageType> =
  // prettier-ignore
  T extends 'auth' ? AuthMessage :
  T extends 'sync' ? SyncMessage :
  never

The conditional type allows defining a “concrete” type based on a discriminating property value.

An alternate here is to declare an explicit unsupported:

type MessageType = 'auth' | 'sync' | 'unsupported'

type UnsupportedMessage = MessageBase<'unsupported'>

type Message<T extends MessageType> =
  // prettier-ignore
  T extends 'auth' ? AuthMessage :
  T extends 'sync' ? SyncMessage :
  UnsupportedMessage

This way, there is always a concrete type.

For those familiar with C#, this may bear some resemblance to C# switch expressions with pattern matching (more on this later). In fact, it behaves very similarly.

Define the Handler Function

The last step is to define the receiver of these messages and routing to the handlers:

// 👇 This is the entrypoint for the generic payload
function receive(msg: Message<MessageType>) {
  switch (msg.type) {
    case 'auth': return handleAuth(msg)
    case 'sync': return handleSync(msg)
    default: return handleUnsupported(msg)
  }
}

// 👇 The specific handlers
function handleAuth(msg: AuthMessage) {}
function handleSync(msg: SyncMessage) {}
function handleUnsupported(msg: UnsupportedMessage) {}

Note how each handler method gets a typed parameter cleanly derived from the msg.type in the switch statement. Each function receives a typed payload correctly and no type assertion with as is required to coerce the types!

This provides a single entry point where messages can be routed with the correct type to standalone handlers. This is a very useful tool for writing maintainable, well-encapsulated code by allowing types to flow through the call chain from a single entry point.

Putting it All Together

type MessageType = 'auth' | 'sync' | 'unsupported'

type MessageBase<T extends MessageType> = {
  type: T,
  id: string,
  payload: string
}

type AuthMessage = {
  identity: string
} & MessageBase<'auth'>

type SyncMessage = {
  timestamp: string
  topic: string
} & MessageBase<'sync'>

type UnsupportedMessage = MessageBase<'unsupported'>

type Message<T extends MessageType> =
  T extends 'auth' ? AuthMessage :
  T extends 'sync' ? SyncMessage :
  UnsupportedMessage

function receive(msg: Message<MessageType>) {
  switch (msg.type) {
    case 'auth': return handleAuth(msg)
    case 'sync': return handleSync(msg)
    default: return handleUnsupported(msg)
  }
}

function handleAuth(msg: AuthMessage) { }
function handleSync(msg: SyncMessage) { }
function handleUnsupported(msg: UnsupportedMessage) { }

Switch Expressions in C#

I mentioned earlier that this strongly resembles switch expressions with pattern matching in C#. How exactly does that look?

void Receive(Message message)
{
  Action result = message switch
  {
    AuthMessage authMessage when message.Type == MessageType.Auth =>
      () => handleAuthMessage(authMessage),
    SyncMessage syncMessage when message.Type == MessageType.Sync =>
      () => handleSyncMessage(syncMessage),
    _ => () => handleUnknownMessage(message),
  };

  result();
}

void handleAuthMessage(AuthMessage authMessage) {}
void handleSyncMessage(SyncMessage syncMessage) {}
void handleUnknownMessage(Message message) {}

enum MessageType { Auth, Sync };

record Message(MessageType Type, string Id, string Payload);

record AuthMessage(string Id, string Payload, string Identity)
  : Message(MessageType.Auth, Id, Payload);

record SyncMessage(string Id, string Payload, string SyncId)
  : Message(MessageType.Sync, Id, Payload);

Here, you can see just how similar these two constructs are at achieving the same result. The key difference is that the C# implementation remains type-safe at runtime as the type metadata remains whereas the TypeScript implementation should probably still be implemented with a schema like Zod or Valibot.

It should be noted that C# switch expressions with pattern matching are much, much more powerful and allow for far more expressive pattern expressions than what has been demonstrated in this basic example. See Tim Deschryver’s excellent writeup for a more in depth exploration.


Closing Thoughts

This mechanism of using TypeScript conditional types can help write easy to maintain, type-safe code paths when the system needs to handle a large number of payload variations without relying on type assertions with as.