optional vs. undefined
Photo by Evan Dennis
- 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:
type Data1 = Record<string, unknown>type Data2 = { [key in string]: unknown }Both definitions are exactly the same. If you hover Data1 it the TypeScript Playground (opens in a new window), 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.
When it comes to object properties or function parameters that are potentially undefined, we also have two common ways of defining them:
declare function canFetch1(networkMode?: NetworkMode): booleandeclare function canFetch2(networkMode: NetworkMode | undefined): booleanAgain, 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:
// ⚠️️️ can be called without the parametercanFetch1()// 🧐 cannot be called without the parametercanFetch2()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.
This is exactly what happened to me when I refactored the function mentioned above for react-query v4 (opens in a new window). 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:
canFetch()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.
As pointed out to me on twitter (opens in a new window), we could also pass a required options object into our function with an optional networkMode property:
declare function canFetch(options: { networkMode?: NetworkMode }): booleanI 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:
function CanFetch1({ networkMode }: { networkMode?: NetworkMode })function CanFetch2({ networkMode }: { networkMode: NetworkMode | undefined })
function App() { return ( <> // 🚨 this is valid, but not what our intention was <CanFetch1 /> // ✅ this is invalid, which is what we want <CanFetch2 /> </> )}Since Version 4.4, TypeScript has a compiler option called exactOptionalPropertyTypes (opens in a new window) 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 (opens in a new window) 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”:
type User = { id: number userName: string}
// 🚨 is that really a partial user?const 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 (opens in a new window) allow us to do:
const merge = <T extends Record<string, unknown>>( obj1: T, obj2: Partial<T>): T => ({ ...obj1, ...obj2 })
const user: User = { id: 23, userName: 'TkDodo',}
// 🚨 TypeScript is fine with this!const newUser = merge(user, { id: undefined })
// 💥 this explodes at runtimenewUser.id.toFixed()The object spread will set our id to undefined on our user Record, and TypeScript is fine with that (try the Playground (opens in a new window)).
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 (opens in a new window)).
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 (opens in a new window) if you have any questions, or just leave a comment below. ⬇️

