Implement Overridable Defaults with TypeScript Generics

2023-03-10

Requirements

  1. Establish a generic type for modifying the input of a generic object.
  2. If the passed-in generic object does not contain the key myId, add a default property such as { myId: string }.
  3. If the passed-in generic object contains myId, then override it with the passed-in property type.

Examples:

/**
 * When the input object does not contain the key `myId`,
 * a default property { myId: string } is added.
 */
type peopleDefaultId = People<{ name: string }>
/* expected:
{
    myId: string // default
    name: string
}
*/

/**
 * When the input object contains `myId`,
 * it will override with the input property type.
 */
type peopleNumberId = People<{ myId: number, name: string }>
/* expected:
{
    myId: number // override
    name: string
}
*/

/**
 * Represents a generic type of People. When the input object contains the `myId` key,
 * the type of the `myId` property will be overridden with the input type. Otherwise, 
 * a default property `{ myId: string }` is added. The `myId` key type retains its
 * original optional/non-optional status.
 */
type peopleOptionalStringId = People<{ myId?: string, name: string }>
/* expected:
{
    myId?: string // override
    name: string
}
*/

TL;DR Solution

type ExtendsDefault<T, Default> = T & Partial<Pick<Default, Exclude<keyof Default, keyof T>>>;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

Pitfalls Encountered

Using | Operator

// Incorrect example
type People<T> = {
  myId: string
} | T

If the input object already includes the myId key (e.g., myId: number), the resulting myId type would erroneously become string | number.

type peopleNumberId = People<{ myId: number, name: string }>

// Expected:
// {
//   myId: number,
//   name: string
// }
// Actual output:
// {
//   myId: string | number,  // Error!
//   name: string
// }

Using extends to Determine Type Usage

interface People<T> {
  myId: T extends { myId: infer U } ? U : string
}

First, check whether the input generic object has myId. If it does, extract the corresponding type; otherwise, default to string.

Although this seems to solve the first pitfall, on closer inspection, since myId above is not an optional key, it forces inclusion of the myId key even when the input generic type is an optional property like myId?: string, which is inconvenient.

// [error] Property 'myId' is missing in type '{}' but required in type 'People<{ myId?: string }>'.
const people: People<{ myId?: string }> = {}
                                          ^^ // error

Conversely, if the default myId is optional, defining a non-null property would erroneously force the property to become optional.

interface People<T> {
  myId?: T extends { myId: infer U } ? U : string
}

const people: People<{ myId: string }> = {}
// output: { myId?: string } <- error: expected to be { myId: string }

Adding Condition to Check if Key is Optional

type People<T extends { myId?: any }> = 'myId' extends keyof T
    ? T extends { myId: infer U } ? { myId: U }
    : T extends { myId?: infer U } ? { myId?: U } : never
  : { myId: string };

This approach meets all requirements, but the code appears somewhat lengthy and complex. A later improvement used [myId in keyof T] to directly determine if myId is an optional property and used it as the key, leading to the following version:

type People<T extends { myId?: any }> = 'myId' extends keyof T
  ? { [myId in keyof T]: T[myId] }
  : { myId: string };

Additionally, a version was created to allow input of a custom key, which is more convenient if the property is not myId.

type DefaultProperty<T extends { [K in keyof T]?: any }, K extends keyof any> = K extends keyof T
  ? { [K in keyof T]: T[K] }


  : { [K in keyof any]: number };

// usage
type People<T, K> = DefaultProperty<T, 'myId'>

However, it was realized that this approach is inconvenient for defining different default types or multiple default properties at once.

Returning to the drawing board, the idea of passing the default object as a generic during the definition of the generic type was considered, conceptually like this:

type ExtendsDefault<T, Default> = ... ;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

This would greatly enhance reusability and support for multiple default properties.

Final Solution

After extensive review of TypeScript documentation, it was discovered that using Pick combined with Exclude could "replace" the properties of one object with another:

  • Pick selects the necessary keys from Default.
  • Exclude excludes keys already present in T from Default.
  • Partial makes the remaining keys in Default optional.
type ExtendsDefault<T, Default> = T & Partial<Pick<Default, Exclude<keyof Default, keyof T>>>;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

🎉  Thanks for reading and hope you enjoy it. Feel free to check out my other posts and find me on X and GitHub!