How to get optional keys from objects in TypeScript?
Type helper explained nº1. OptionalKeysOf<T>
— by Gabriel Vergnaud · Oct 22, 2024
In TypeScript, you can define optional properties on object types with the ?:
modifier:
type User = {
name: string;
age: number;
// 👇 `role` and `plan` are optional!
role?: "admin" | "user";
plan?: "basic" | "premium";
};
With the keyof
keyword, retrieving all keys from an object type is trivial, but is there a way to only get its optional keys?
type KeysOfUser = keyof User; // => 'name' | 'age' | 'role' | 'plan'
type OptionalKeysOfUser = 🤔❓; // => 'role' | 'plan'
📚 TL;DR
If you need to get optional keys from an object type, here is how to do it:
export type OptionalKeysOf<Obj> = keyof {
[Key
in keyof Obj
as Omit<Obj, Key> extends Obj ? Key : never
]: Obj[Key];
};
type Keys = OptionalKeysOf<User>; // =>
export type OptionalKeysOf<Obj> = keyof {
[Key
in keyof Obj
as Omit<Obj, Key> extends Obj ? Key : never
]: Obj[Key];
};
type Keys = OptionalKeysOf<User>; // =>
This type helper takes any object types and returns the union of its optional keys:
import { OptionalKeysOf } from "@/OptionalKeysOf";
type test1 = OptionalKeysOf<{ a?: string }>; // =>
type test2 = OptionalKeysOf<{ a?: string; b: number }>; // =>
type test3 = OptionalKeysOf<{ a?: string; b?: number }>; // =>
type test4 = OptionalKeysOf<{ a: string }>; // =>
import { OptionalKeysOf } from "@/OptionalKeysOf";
type test1 = OptionalKeysOf<{ a?: string }>; // =>
type test2 = OptionalKeysOf<{ a?: string; b: number }>; // =>
type test3 = OptionalKeysOf<{ a?: string; b?: number }>; // =>
type test4 = OptionalKeysOf<{ a: string }>; // =>
Keep reading if you want to better understand how OptionalKeysOf
works!
🤔 Why would you need this?
As an example, let's say you want to build a generic withDefaults
function.
withDefaults
takes any object and a set of default values for optional properties. It returns an object that is guaranteed to have no undefined properties.
To properly type this function, you need OptionalKeysOf
:
import { OptionalKeysOf } from "@/OptionalKeysOf";
type PickOptionals<Obj> = Pick<
Obj,
OptionalKeysOf<Obj>
>;
export function withDefaults<Obj>(
obj: Obj,
defaults: Required<PickOptionals<Obj>>,
): Required<Obj> {
return { ...defaults, ...obj };
}
Using withDefaults
, you can safely provide default values to any object in your codebase and modify their type to reflect this!
import { withDefaults } from "@/withDefaults";
function main(user: User) {
// ❌ role might be undefined!
user.role.toUpperCase();
// ^?
const fullUser = withDefaults(user, {
role: "user",
plan: "basic",
});
fullUser.plan.toUpperCase(); // ✅ 🎉
fullUser.role.toUpperCase(); // ✅ ✨
// ^?
}
import { withDefaults } from "@/withDefaults";
function main(user: User) {
// ❌ role might be undefined!
user.role.toUpperCase();
// ^?
const fullUser = withDefaults(user, {
role: "user",
plan: "basic",
});
fullUser.plan.toUpperCase(); // ✅ 🎉
fullUser.role.toUpperCase(); // ✅ ✨
// ^?
}
Crucially, if you ever forget any key in your defaults, it won't type-check:
import { withDefaults } from "@/withDefaults";
function main(user: User) {
// ❌ Whoops! `plan` is missing!
const fullUser1 = withDefaults(user, { role: "user" });
const fullUser2 = withDefaults(user, {
// ❌ Whoops! "typo!" isn't a valid role:
role: "typo!",
plan: "basic",
});
}
import { withDefaults } from "@/withDefaults";
function main(user: User) {
// ❌ Whoops! `plan` is missing!
const fullUser1 = withDefaults(user, { role: "user" });
const fullUser2 = withDefaults(user, {
// ❌ Whoops! "typo!" isn't a valid role:
role: "typo!",
plan: "basic",
});
}
🤯 How does it work?
The definition of OptionalKeysOf
is pretty complex:
export type OptionalKeysOf<Obj> = keyof {
[Key
in keyof Obj
as Omit<Obj, Key> extends Obj ? Key : never
]: Obj[Key];
};
Let's see how it works, step by step. 🚶♀️
1. Looping over object keys
To loop over the keys of an object type, you can use a Mapped Type. They look like this:
type SomeUnion = "a" | "b";
type test = {
[Key in SomeUnion]: DoSomething<Key>;
// 👆
};
The in
keyword loops over all members of a union type and creates a property for each of them. Here, we use in
to loop over the keyof Obj
union:
type SomeMappedType<Obj> = {
[Key in keyof Obj]: DoSomething<Key>;
// 👆
};
2. Filtering keys with as
Mapped Types have a neat feature called "Key Remapping". It lets you rename object keys using the as
keyword:
// Renames each key to `new_${key}`:
export type RenameKeys<Obj> = {
[Key in keyof Obj as `new_${Key}`]: Obj[Key];
// 👆
};
type T = RenameKeys<{ id: number; name: string }>;
// ^? { new_id: number; new_name: string }
Now, here is something a little bit surprising: if you rename a key to never
, the key is filtered out from the object!
export type RemoveAllKeys<Obj> = {
[Key in keyof Obj as never]: Obj[Key];
// 👆
};
type T = RemoveAllKeys<{ id: number; name: string }>;
// ^?
export type RemoveAllKeys<Obj> = {
[Key in keyof Obj as never]: Obj[Key];
// 👆
};
type T = RemoveAllKeys<{ id: number; name: string }>;
// ^?
💡 Not sure what the never
type is? Take a look at look at chapter nº2 of Type-Level TypeScript: Types Are Just Data. It's free!
If only we had a way to check if a property was optional, we could utilize this behavior to filter out required keys from our object... 🤔
3. Testing if a property is optional
In TypeScript, you can use a Conditional Types to check if a type is assignable to another type:
type IsOneANumber = 1 extends number ? true : false;
// ^?
type IsOneANumber = 1 extends number ? true : false;
// ^?
In OptionalKeysOf
, we check if Omit<Obj, Key>
is assignable to Obj
, for every key Key
inside the objectObj
.
type OptionalKeysOf<Obj> = {
[... as Omit<Obj, Key> extends Obj ? ... : ...]: ...
/* --------------------------
👆 */
};
Omit<Obj, Key>
removes one of the keys from Obj
:
// ✅ type-checks
const noName: Omit<User, "name"> = { age: 42 };
// ✅ type-checks
const noName: Omit<User, "name"> = { age: 42 };
If you want an object to be assignable to a type Obj
, all its required properties must be present! The only way for Omit<Obj, Key>
to be assignable to Obj
is if Key
is an optional property:
type IsOptional<Obj, Key extends PropertyKey> =
Omit<Obj, Key> extends Obj ? true : false;
type test1 = IsOptional<User, "age">; // =>
type test2 = IsOptional<User, "role">; // =>
type IsOptional<Obj, Key extends PropertyKey> =
Omit<Obj, Key> extends Obj ? true : false;
type test1 = IsOptional<User, "age">; // =>
type test2 = IsOptional<User, "role">; // =>
In OptionalKeysOf
, if Key
is optional we simply return it, otherwise, we return never
.
type OptionalKeysOf<Obj> = {
[...
as IsOptional<Obj, Key> extends true ? Key : never
/* -----------
👆 */
]: ...
};
This has the effect of removing any key that isn't optional from the object!
The code we wrote so far creates an object that looks like:
// Output of `OptionalKeysOf<User> so far:
type WorkInProgress = {
role?: "admin" | "user";
plan?: "basic" | "premium";
};
The only thing left to do is to retrieve its keys with keyof
:
type OptionalKeys = keyof WorkInProgress; // => "role" | "plan"
That's it!
📚 Summary
When writing generic code to manipulate objects, it's sometimes useful to know how to dynamically retrieve optional properties from an object type. You can do it by:
- Looping over the object's properties with a Mapped Type.
- Check if they are optional with a Conditional Types.
- Remove required keys with an
as never
remapping. - Extract the union of all keys with the
keyof
keyword.
Here is a refactored version of OptionalKeysOf
that highlights these steps:
export type Result = OptionalKeysOf<User>; // =>
type OptionalKeysOf<Obj> = keyof PickOptionalKeys<Obj>;
type PickOptionalKeys<Obj> = {
[Key
in keyof Obj
as IsOptional<Obj, Key> extends true ? Key : never
]: Obj[Key];
};
type IsOptional<Obj, Key extends PropertyKey> =
Omit<Obj, Key> extends Obj ? true : false;
export type Result = OptionalKeysOf<User>; // =>
type OptionalKeysOf<Obj> = keyof PickOptionalKeys<Obj>;
type PickOptionalKeys<Obj> = {
[Key
in keyof Obj
as IsOptional<Obj, Key> extends true ? Key : never
]: Obj[Key];
};
type IsOptional<Obj, Key extends PropertyKey> =
Omit<Obj, Key> extends Obj ? true : false;
Happy typing ✌️
Gabriel
If you liked this article, chances are you'll like Type-Level Typescript. It's an advanced TypeScript course that will give you a solid understanding of the type system's fundamentals and guide you through its most advanced features. Enroll Now!
Subscribe to the newsletter!
Receive all new chapters and articles from Type-Level TypeScript directly in your inbox!