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
}
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'
}
A message to advance the task to the second step would look like:
const advanceMessage = {
taskId: 1,
newStep: 2,
action: 'advance'
}
And finally to end a task:
const message = {
taskId: 1,
action: 'stop'
}
You could decide to type this message as such:
interface Message {
taskId: number
newStep?: number
action: string
}
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)
}
}
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
}
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
}
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
}
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
}
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
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)
}
}
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
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.
Top comments (0)