loading...
Cover image for Optional properties vs Union Types

Optional properties vs Union Types

qmenoret profile image Quentin Ménoret ・3 min read

There are several way to implement complex objects types in TypeScript. Not all of them will bring the same safety to your code.

Reminder: What are optional properties?

In typescript, an optional property is used to define a field or class member that may or may not be present.

For instance, let’s say you have an interface to represent a user. When they create their account you will ask for their name (mandatory to fill) but the birth date field won’t be mandatory.

You can represent this using an optional property:

interface User {
  id: number
  name: string
  birthdate?: string
}
Enter fullscreen mode Exit fullscreen mode

It's very common in TypeScript to use this feature. But there are cases where you should think before adding this ?.

A more complex example

Let’s imagine you are now writing a function that can receive messages and depending on its content you would apply different actions.

This function allows you to start, advance or stop tasks.

A message to start the first task would look like this:

const startMessage = {
  taskId: 1,
  action: 'start'
}
Enter fullscreen mode Exit fullscreen mode

A message to advance the task to the second step would look like:

const advanceMessage = {
  taskId: 1,
  newStep: 2,
  action: 'advance'
}
Enter fullscreen mode Exit fullscreen mode

And finally to end a task:

const message = {
  taskId: 1,
  action: 'stop'
}
Enter fullscreen mode Exit fullscreen mode

You could decide to type this message as such:

interface Message {
  taskId: number
  newStep?: number
  action: string
}
Enter fullscreen mode Exit fullscreen mode

The function to process such message would look like this:

function onMessage(message: Message) {
  if (message.action === 'start') {
    startTask(message.taskId)
  } else if (message.action === 'advance') {
    // Notice the !, because typescript
    // does not know if newStep is defined
    advanceTask(message.taskId, message.newStep!)
  } else if (message.action === 'stop') {
    stopTask(message.taskId)
   }
}
Enter fullscreen mode Exit fullscreen mode

With this design, you either end up with ! operators everywhere (hence typescript becomes useless), or with code that looks like this:

function advanceTask(taskId: number, newStep?:number ) {
  if (!newStep) return
  // here you know newStep is defined
}
Enter fullscreen mode Exit fullscreen mode

Even worse, you could create message objects that looks like:

const wrongMessage: Message = {
  taskId: 1,
  newStep: 4, // makes no sense, property is useless
  action: 'stop'
}

const evenWorseMessage: Message = {
  taskId: 1,
  action: 'anything here' // We don't even restrict the action
}
Enter fullscreen mode Exit fullscreen mode

We really need to improve this design.

Union types to the rescue

Let’s try to make this design a bit better. First, let’s define the values you can use in the action field:

enum Action {
  Start = 'start',
  Stop = 'stop',
  Advance = 'advance'
}
interface Message {
  taskId: number
  newStep?: number
  action: Action
}
Enter fullscreen mode Exit fullscreen mode

We now restrict the types of messages we can create. It's already cleaner. But we could be more precise. We know what data each type of message can hold:

interface StartMessage {
  taskId: number
  action: Action.Start
}
interface AdvanceMessage {
  taskId: number
  newStep: number
  action: Action.Advance
}
interface StopMessage {
  taskId: number
  newStep: number
  action: Action.Stop
}
Enter fullscreen mode Exit fullscreen mode

With these types, we can use a union to define what a message actually is:

// The | operator allow to define a Union, meaning that a Message
// can be any of these types (but not a mix of them)
type Message = StartMessage | AdvanceMessage | StopMessage
Enter fullscreen mode Exit fullscreen mode

Now the function would look like this:

function onMessage(message: Message) {
  if (message.action === 'start') {
    startTask(message.taskId)
  } else if (message.action === 'advance') {
    // Notice that we don't need the ! anymore
    advanceTask(message.taskId, message.newStep)
  } else if (message.action === 'stop') {
    stopTask(message.taskId)
   }
}
Enter fullscreen mode Exit fullscreen mode

Typescript can actually understand the dependency between the type of the message and the fields that will be present. This brings us more safety as we know for sure what fields are present or not based on the message type. It's even more critical when your object has several depth layer.

You can even go further and use generics, since all messages share the same structure:

interface BaseMessage<T extends Action> {
  taskId: number
  action: T
}
type StartMessage = BaseMessage<Action.Start>
type StopMessage = BaseMessage<Action.Stop>
interface AdvanceMessage extends BaseMessage<Action.Advance> {
  newStep: number
}

type Message = StartMessage | AdvanceMessage | StopMessage
Enter fullscreen mode Exit fullscreen mode

This is of course a very simplified example, but it can be applied to a lot of cases in Typescript and give you more safety when coding.

Discussion

pic
Editor guide