Recently I saw a tweet by Jamie Kyle about using destructuring, default params, and inline types:
That tweet and a few React components that I saw recently in my day job inspired me to write this blog post. I want to show you how using destructuring and inline types can make your TypeScript less readable!
In JavaScript and TypeScript, you can define a function either using function
keyword or lambda/arrow function. Both ways are valid but have their differences. Let's take a look at the simple sendMessage
function. Implementation logic is not relevant to us.
// sendMessage function written using `function` keyword
function sendMessage(message: string) {
// function logic
}
// same sendMessage written as arrow function
const sendMessage = (message: string) => {
// function logic
};
When function definition is quite simple, the function accepts a few parameters of a different type. If they are primitives like strings or numbers, everything is readable.
Let's say you want to pass some additional information alongside your message content to the sendMessage
function.
function sendMessage(message: {
content: string;
senderId: string;
replyTo?: string;
}) {
// you can assess content using `message.content` here
}
As you can see, TypeScript allows you to write an inline type definition for the message
object you want to pass without specifying the type using type
or interface
keyword.
Let's add Destructuring. When you pass a large message
object to your function, TypeScript allows breaking apart passed arguments to reduce the code boilerplate of repeating message
variable many times.
function sendMessage({
content,
senderId,
replyTo,
}: {
content: string;
senderId: string;
replyTo?: string;
}) {
// you have access to `content` directly
}
It may look like a nice idea, after all, you don't need to write message
that many times, right? It turns out it's not that great. Let's talk about 5 reasons why I think it's an antipattern.
When you're reading the function body, you see senderId
you have to double-check to be sure from where that function comes. Is it passed as an argument or calculated somewhere in the function?
There is no natural place to write documentation comments when all the types are cramped with destructuring in the function definition. You could write comments between each type field, but that makes the whole function definition even longer. It's actively discouraging you from writing a quick summary of the data you're passing.
When your data is destructured you need to structure it again into a new object if you want to pass it forward. This discourages creating smaller helper functions and relying on composition to build up your main function logic.
If you need to reuse your function arguments in helper functions when composing your main function logic, you must type the same set of types repeatedly. This makes it easier not to write types at all.
Let's face it. It's just a lot of lines of code that take up a lot of screen space. And in addition, it focuses on implementation detail – the inner type of the arguments you are passing to a function, which is most of the time not relevant when you're looking at that function.
Extracting the type and placing it right above the function makes it much more readable. There is a place for documentation comments, you can reuse that type in some other helper function and change the type definition in one place if needed.
/**
* Message to send using XYZ API
*/
export type MessageToSend = {
/**
* Markdown string of the user's message
*/
content: string;
/**
* Id of the sender user
*/
senderId: string;
/**
* Other message ID if this is a reply
*/
replyTo?: string;
};
function sendMessage(message: MessageToSend) {
// function logic
}
function getUserIdsToNotify(message: MessaageToSend) {
// function logic
}
Find a list of resources I used when researching this blog post: