Most of the narrowing behavior demonstrated by TypeScript primarily affects the value being checked. In other words, while an operation on expression exp1
could change the apparent type of exp1
, it typically won't alter the apparent type of a different expression like exp2
.
There are exceptions to this rule: inspecting the discriminant property of a discriminated union object will narrow the type of the object itself; you can save the result of a type guard check as a boolean and later use that value to further narrow the original expression; and destructuring discriminated unions into separate variables allows for one variable's check to narrow the other.
However, your situation does not fall into any of these exceptions. The language mechanisms do not propagate a check on x
to impact the apparent type of doc
. Even if future updates implement broader property type guards or expand specific features, they still wouldn't achieve the behavior you desire. Enabling such functionality would involve extensive counterfactual analysis by the compiler, making it impractical.
So, what alternatives can you consider?
If restructuring your code is not ideal, you have the option to utilize a type assertion to explicitly inform the compiler that doc
should be treated as MyDoc
:
if (x === undefined) throw new Error();
insert(doc as MyDoc); // assertion
This approach eliminates errors at the cost of manual verification for type safety. For instance, there won't be an error even if an incorrect check is performed:
if (x !== undefined) throw new Error(); // mistake
insert(doc as MyDoc); // assertion remains valid
Hence, exercise caution with this method.
If your goal is to align the compiler's reasoning with your logic, refactoring using supported narrowing techniques is necessary. One effective strategy involves creating a user-defined type guard function to detail the narrowing process to the compiler. While still limited to the checked value, it allows for complex typings to be expressed.
For example, you could define a function to validate whether a value represents a valid MyDoc
:
function isMyDoc(x: any): x is MyDoc {
return x && ("field" in x) && (typeof x.field === "string");
}
The compiler doesn't verify the inner workings of this function, but it facilitates its usage elsewhere:
if (!isMyDoc(doc)) throw new Error();
insert(doc); // permissible
Alternatively, you could develop a more generic guard that checks for a defined property at a specific key within an object:
function hasDefinedProp<T extends object, K extends keyof T>(
obj: T, key: K): obj is T & { [P in K]-?: Exclude<T[K], undefined> } {
return typeof obj[key] !== "undefined"
}
Although more intricate, this typing proves useful:
if (!hasDefinedProp(doc, "field")) throw new Error();
doc;
// { field: string | undefined } & { field: string }
insert(doc); // permissible
In this scenario, doc
transitions from { field: string | undefined }
to
{ field: string | undefined } & { field: string}
, thereby aligning with
MyDoc
. Depending on the frequency of such checks in your codebase, the versatility of
hasDefinedProp()
may overshadow that of
isMyDoc()
.
To enhance clarity and maintain clean code, consider reorganizing your code to precede aliasing operations with the relevant checks:
const x = Math.random() < 0.5 ? "abc" : undefined;
if (x === undefined) throw new Error(); // establish guard first
let doc = { field: x }; // then proceed
insert(doc); // permissible
Though not always feasible, this refactoring ensures a straightforward understanding of the sequence of type guards by both the compiler and developers.
Explore the code on Playground