Chapters

  • 0. Introduction

    Free

  • 1. Types & Values

    Free

  • 2. Types are just data

    Free

  • 3. Objects & Records

    Free

  • 4. Arrays & Tuples

    Free

  • 5. Conditional Types
  • 6. Loops with Recursive Types
  • 7. Template Literal Types
  • 8. The Union Type Multiverse
  • 9. Loops with Mapped Types
  • 10. Assignability Quiz
  • 11. Designing Type-Safe APIs
  • 12. Conclusion

Articles

Subscribe

About

Made with ❤️ by @GabrielVergnaud

|Chapters|Articles

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!

?:
:

Only receive Type-Level TypeScript content. Unsubscribe anytime.