'How to implement "If not interface, add necessary properties to match"?

I struggled with the title, but the code example is very straightforward:

// Setup
interface Dog {
    name: string;
}

interface Dachshund extends Dog {
    length: number;
}

function isDachshund (dog: Dog): dog is Dachshund {
    return (dog as Dachshund).length !== undefined;
}

// Problem
// We have an object that could match two different interfaces
let unknownDog: Dog | Dachshund = { name: "Rufus" };

// If it's not this specific interface, add the missing property
if (!isDachshund(unknownDog)) {
    (unknownDog as Dachshund).length = 5;
}

unknownDog // compiler still thinks Dog | Dachshund, but we know for certain it's a Dachshund at this point

Is it possible to get the TypeScript compiler to infer that we've added the necessary properties to narrow down the union type? Is there a different way of structuring my types to avoid this kind of problem?



Solution 1:[1]

Interesting question.

As far as I can see, (and by all means someone correct me if I'm wrong) it looks like TypeScript simply won't support what you're specifically trying to do here.

See this github issue: https://github.com/microsoft/TypeScript/issues/27568

Essentially what you're trying to do is get TypeScript to be aware of mutations you've made to an object of a wider type, and infer correctly the narrower type.

The above Github issue seems to suggest 'Nope, too hard'.

Note that TypeScript won't keep track of mutations of the sort when using no type annotations!

const rufus = {
    name: "foo"
}; 


rufus.length = 9; //Property 'length' does not exist on type '{ name: string; }'.(2339)

What I would do here is just not mutate the object, instead just declare new objects:


function usesADog(unknownDog: Dog | Dachshund){

    const dogToUse = isDachshund(unknownDog) ? unknownDog: {...unknownDog, length: 5}; 

    dogToUse; //const dogToUse: Dachshund

}

Playground

Solution 2:[2]

Typescript knows that the type of unknownDog is Dachshund, only in any block guarded by a call to isDachshund function. Because the function return type is dog is Dachshund(type predicate).

if (!isDachshund(unknownDog)) {
    ... //here the type of unknownDog is Dog.  
}

else {
   ... // and here the type is Dachshund
}

// but here again the type is Dog|Dachshund

So, in if block you can write the codes you want to be executed, if the type of unknownDog is Dog.
In else block the codes you want to be executed, if the type of unknownDog is Dachshund.

Solution 3:[3]

You can use an assertion function to narrow the type of one of its parameters. It's similar to a user defined type guard function, but instead of returning a boolean and narrowing depending on the result, you return void and narrow unconditionally.

Here's a function which can be used to add a property to an object and narrow the apparent type of that object:

function addProp<T extends object, K extends PropertyKey, V>(
    obj: T, k: K, v: V): asserts obj is T & Record<K, V> {
    (obj as any)[k] = v;
}

Now your code can use it:

let unknownDog: Dog | Dachshund = { name: "Rufus" };

if (!isDachshund(unknownDog)) {
    addProp(unknownDog, "length", 5);
}

And the compiler will successfully understand that after this, unknownDog is definitely a Dachshund:

unknownDog // Dachshund
console.log(unknownDog.length.toFixed(2)); // 5.00

Playground link to code

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 dwjohnston
Solution 2
Solution 3 jcalz