Programming

Preserving TypeScript Conditional Type Inference During Destructuring

Learn how to preserve TypeScript's conditional type inference when destructuring and reconstructing objects to avoid type assignment errors. Explore patterns with discriminated unions.

1 answer 1 view

How can TypeScript’s conditional type inference be preserved when destructuring an object and then reconstructing it, to avoid type assignment errors?

Consider the following TypeScript example where destructuring an object with a conditional type leads to a loss of type information, resulting in a type assignment error when the object is reconstructed:

ts
type Foo = "A" | "B";

type Bar<TFoo> = TFoo extends "A"
  ? { type: TFoo; foobar: number }
  : { type: TFoo; foobar: string };

function getBar(type: Foo): Bar<Foo> {
  if (type === "A") {
    return { type, foobar: 42 };
  } else {
    return { type, foobar: "hello world" };
  }
}

function doBar<T extends Foo>(bar: Bar<T>) {
  // Do something with bar
}

const { type: typeA, foobar: foobarA } = getBar("A");
doBar({ type: typeA, foobar: foobarA }); // Typescript Error

const barA = getBar("A");
doBar(barA); // No Typescript Error

The error received is:

Argument of type ‘{ type: “A” | “B”; foobar: string | number; }’ is not assignable to parameter of type ‘{ type: “A”; foobar: number; } | { type: “B”; foobar: string; }’

Is there a recommended pattern or technique to destructure such objects and then re-create them without losing the original conditional type information, or does the original object need to be passed as-is to maintain type safety in such scenarios?

TypeScript’s conditional type inference can be preserved when destructuring and reconstructing objects by using techniques like as const assertions, preserving discriminant properties, or using utility functions that maintain type information. The key challenge is that destructuring loses the connection between the discriminant property and the rest of the fields, causing TypeScript to widen the types. By using literal type preservation or working with the original object reference, you can maintain type safety without passing the entire object as-is.

Contents

Understanding the Problem with Destructuring Conditional Types

In TypeScript, discriminated unions (also called tagged unions) allow you to create type-safe code by using a common discriminant property to differentiate between possible shapes of an object. When you destructure such an object, TypeScript loses the connection between the discriminant property and the rest of the fields, causing type information to be lost.

In your example:

ts
const { type: typeA, foobar: foobarA } = getBar("A");
doBar({ type: typeA, foobar: foobarA }); // TypeScript Error

The error occurs because after destructuring, TypeScript infers typeA as "A" | "B" and foobarA as string | number, losing the specific connection between the values. When reconstructing the object, TypeScript can’t narrow the type back to the discriminated union.

Root Causes of Type Inference Loss

The type inference loss when destructuring conditional types occurs due to several factors:

  1. Type Widening: When you destructure an object, TypeScript widens the types of individual properties. For example, type: "A" becomes type: "A" | "B".

  2. Loss of Discriminant Connection: TypeScript relies on the discriminant property to narrow types in discriminated unions. Once you destructure and separate the discriminant from other properties, this connection is broken.

  3. Contextual Typing: TypeScript’s type inference is context-dependent. Outside the original object context, the types lose their specific relationships.

According to the dev.to article on discriminated unions, “When you create an array of literals, TypeScript infers the exact subtype: Inside the array literal, each object is inferred as Person or Robot. Outside that context, the variable is just Entity.”

Solution 1: Using Literal Type Preservation

One effective approach is to use as const assertions to preserve literal types when destructuring:

ts
const bar = getBar("A") as const;
const { type: typeA, foobar: foobarA } = bar;
doBar({ type: typeA, foobar: foobarA }); // No TypeScript Error

The as const assertion preserves the literal types of the object, preventing type widening during destructuring. This technique is recommended in the Mayallo.com article on TypeScript discriminated unions: “When creating objects that will be part of a discriminated union, use as const to lock property values to literals.”

Solution 2: Preserving Discriminant Properties

Another approach is to always keep the discriminant property intact and avoid destructuring it separately:

ts
const { foobar: foobarA, ...rest } = getBar("A");
doBar({ ...rest, foobar: foobarA }); // No TypeScript Error

By using the rest syntax to extract all properties except the discriminant, you preserve the type information. The discriminant property type remains part of the object, maintaining the discriminated union structure.

Solution 3: Working with Object References

The simplest solution is to avoid destructuring altogether and work with the object reference directly:

ts
const barA = getBar("A");
doBar(barA); // No TypeScript Error

This approach ensures that TypeScript maintains the full type information throughout your code. While it may not always be practical for cases where you need individual values, it’s the most type-safe approach.

Solution 4: Utility Functions for Type Reconstruction

For more complex scenarios, you can create utility functions that help preserve type information during destructuring and reconstruction:

ts
function reconstructBar<T extends Foo>(type: T, foobar: Bar<T>["foobar"]): Bar<T> {
  return { type, foobar };
}

const { type: typeA, foobar: foobarA } = getBar("A");
const reconstructedBar = reconstructBar(typeA, foobarA);
doBar(reconstructedBar); // No TypeScript Error

This approach leverages TypeScript’s generics to maintain the relationship between the discriminant property and the other fields.

Best Practices for Conditional Type Handling

To maintain type safety when working with conditional types and discriminated unions:

  1. Use as const for Literal Preservation: As mentioned in the Mayallo.com article, using as const helps preserve literal types during object creation and destruction.

  2. Avoid Destructuring Discriminants: Keep the discriminant property intact with the rest of the object to maintain type narrowing.

  3. Use Type Assertions Judiciously: While type assertions can help, use them sparingly and ensure they’re type-safe.

  4. Leverage Generics: Create utility functions with proper generic constraints to maintain type relationships.

  5. Consider the Context: Be aware of where type inference might be lost and adjust your code accordingly.

According to the Ceos3c.com article on TypeScript discriminated unions, “The switch statement automatically narrows state to the matching variant. Use literal types for the discriminants: type ValidDiscriminant = { kind: ‘valid’; data: string }; type InvalidDiscriminant = { kind: string; data: string }; // too broad.”

Conclusion

When working with TypeScript’s conditional types and discriminated unions, destructuring can lead to type inference loss if not handled properly. The recommended patterns include using as const assertions to preserve literal types, keeping discriminant properties intact with the object, working with object references directly, or creating utility functions that maintain type relationships. By understanding how TypeScript handles discriminated unions and type narrowing, you can write more type-safe code that leverages these powerful TypeScript features without losing type information during destructuring and reconstruction operations.

Sources

Authors
Verified by moderation
Moderation
Preserving TypeScript Conditional Type Inference During Destructuring