optional vs. undefined
— TypeScript, JavaScript, ReactJs, React Query — 3 min read
- No translations available.
- Add translation
TypeScript sometimes gives us different ways to represent the same thing. For example, you can define an object with an index signature in many ways:
1type Data1 = Record<string, unknown>2type Data2 = { [key in string]: unknown }
Both definitions are exactly the same. If you hover Data1 it the TypeScript Playground, it even expands to the Data2 syntax. It is perfectly fine to use either way, and it's totally up to developer or team preference to decide what they want to choose. Most people I know prefer the first way because it's shorter, and you don't need to invent a name like key - and so do I.
optional properties
When it comes to object properties or function parameters that are potentially undefined, we also have two common ways of defining them:
1declare function canFetch1(networkMode?: NetworkMode): boolean2declare function canFetch2(networkMode: NetworkMode | undefined): boolean
Again, most developers will choose the first way without thinking twice (because it's shorter). The difference to the previous example is that those two definitions are not the same.
- The first one means: This parameter is optional.
- The second one means: This parameter is required, but can be undefined.
This creates a difference in how they can be invoked:
1// ⚠️️️ can be called without the parameter2canFetch1()3// 🧐 cannot be called without the parameter4canFetch2()
Especially when we create util functions for internal usage, we should ask ourselves if it makes sense for our use-case to call the function without parameters at all.
Refactoring gone wrong
This is exactly what happened to me when I refactored the function mentioned above for react-query v4. I added this parameter, which is supposed to come from an options object passed by the user. In case it is left empty there, we want to fall back to a default value.
In the first version, I went with the optional parameter, fixed my usage and called it a day. To my surprise, ~50% of all tests started to fail. 🚨
I had of course forgotten to adapt all other usages, because TypeScript was not complaining (I do rely on that a lot), as all calls to canFetch without the parameter were still considered valid:
1- canFetch()2+ canFetch(options.networkMode)
After that, I changed the signature to require the parameter. This should also make it easier for future usages when someone wants to call that function.
Optional parameters are often interpreted as: "oh, I can just leave that one out, and it will still work". If that applies to your situation, then it is the correct definition. But if that would be invalid, as in the above case, consider the longer notation. The important part is to know the difference and be explicit about intent.
A similar solution
As pointed out to me on twitter, we could also pass a required options object into our function with an optional networkMode property:
1declare function canFetch(options: { networkMode?: NetworkMode }): boolean
I like this solution a lot in this situation, because we can mostly just forward our whole options object. The problem is that you could inadvertently pass in a "wrong" options object that doesn't even have a networkMode, because any object, even an empty one, conforms to this interface.
It also doesn't work so well if we try to take this concept to React components, because of the extra object layer that is introduced by props:
1function CanFetch1({ networkMode }: { networkMode?: NetworkMode })2function CanFetch2({ networkMode }: { networkMode: NetworkMode | undefined })3
4function App() {5 return (6 <>7 // 🚨 this is valid, but not what our intention was8 <CanFetch1 />9 // ✅ this is invalid, which is what we want10 <CanFetch2 />11 </>12 )13}
exactOptionalPropertyTypes
Since Version 4.4, TypeScript has a compiler option called exactOptionalPropertyTypes that can help us distinguish a bit better between the two cases for objects.
With the flag turned on, we cannot pass undefined explicitly to a property that is marked as optional. The other way around was always handled well by TypeScript - we cannot omit a property that is required but potentially undefined.
This has a big positive impact when working with the built-in Partial type. That type marks all properties of an object as optional, but without exactOptionalPropertyTypes, there is no real difference between a property value being "missing" or "there, but undefined":
1type User = {2 id: number3 userName: string4}5
6// 🚨 is that really a partial user?7const partialUser: Partial<User> = { id: undefined }
That looks so weird - A user with an id that is set to undefined. Definitely not what we wanted to express with Partial...
This becomes especially dangerous when we want to use object spread to merge a partial object onto a full object. This is what state management libraries like zustand allow us to do:
1const merge = <T extends Record<string, unknown>>(2 obj1: T,3 obj2: Partial<T>4): T => ({ ...obj1, ...obj2 })5
6const user: User = {7 id: 23,8 userName: 'TkDodo',9}10
11// 🚨 TypeScript is fine with this!12const newUser = merge(user, { id: undefined })13
14// 💥 this explodes at runtime15newUser.id.toFixed()
The object spread will set our id to undefined on our user Record, and TypeScript is fine with that (try the Playground).
You might not explicitly set your ids to undefined, but you might set some fields that can actually be undefined to undefined. If you later refactor those fields to be required, we would expect TypeScript to report the wrong call-sides to us.
And that's exactly what you'll get with the new flag turned on (try this Playground).
Summary
Required, but potentially undefined does not have the same intent as optional. Try to be explicit with your type declarations and get some help from TypeScript by opting into new features like exactOptionalPropertyTypes.
That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️