mikemaccana
mikemaccana

Reputation: 123288

Do I need to tell TypeScript when a partial is complete, and if so, what's the best way to do that?

I am making an object that initially has only part of the type's required properties, so I am using Partial<Type> to tell TypeScript that's OK.

Later I add the missing property (address). Right now I'm using as to tell TypeScript that the RawAccountWithAddress is complete - i.e. that the type is now RawAccountWithAddress rather than Partial<RawAccountWithAddress>. Using as feels like a hack though.

const rawAccountWithAddress: Partial<RawAccountWithAddress> = ...
rawAccountWithAddress.address = ... 
return rawAccountWithAddress as RawAccountWithAddress;

Is this the best way to tell TypeScript the Partial<Type> is now Type? Shouldn't TypeScript just know that the partial is now complete, since the missing property has been assigned?

Is there a better way of doing this?

For reference the type is:

interface RawAccountWithAddress extends RawAccount {
  address: PublicKey;
}

Upvotes: 0

Views: 1549

Answers (1)

jcalz
jcalz

Reputation: 328748

You're expecting assignment narrowing to apply to the parent object when a property is set, but that doesn't happen, because it would involve synthesizing new object types anytime a property changes, and that would be expensive for the compiler. Still, there is an open issue suggesting this sort of gradual initialization at microsoft/TypeScript#35086, and a more general open issue for narrowing parent objects upon narrowing of their properties at microsoft/TypeScript#42384. If you're interested in seeing this happen you might want to go to those issues and give them a 👍 and/or comment describing why your use case is compelling and why workarounds aren't sufficient.

One workaround which may be sufficient, depending on the use case, is to wrap the concept of "setting a property narrows the parent" in its own assertion function. Here's one way to write it:

function setProp<
    T extends object,
    K extends PropertyKey,
    V extends K extends keyof T ? T[K] : any
>(obj: T, key: K, val: V): asserts obj is
    Extract<(T & Record<K, V>) extends infer O ? {
        [P in keyof O]: O[P]
    } : never, T> {
    (obj as any)[key] = val;
}

That basically says that after you call setProp(obj, key, val), the compiler can narrow obj from whatever type it started with to a version of that type where its known to have a key of key whose value is the type of val. Here's a sketch of how it works:

  • This is a generic function in three type parameters T, K, and V corresponding to the three function parameters obj, key, and val respectively.

  • The V type parameter is constrained to a conditional type so that if obj already has a key key, then val must be some subtype of the property value there. This will usually prevent someone from clobbering an existing property with an invalid type.

  • The output type is based on an intersection of the original type T and the Record<K, V> utility type meaning an object with properties of key type K and value type V. Then we take this intersection and use conditional type inference, mapped types and the Extract<T, U> utility type to make the output type prettier than an intersection. If this is too confusing you could just use T & Record<K, V>.

Again, it depends on use case whether this form works for you. The general idea is that we can convey that the act of calling obj[key] = val can be treated as a narrowing on obj.

Let's try it out:

interface Foo {
    bar: string;
    baz: number;
}

function makeFoo(): Foo {
    const foo = {};
    setProp(foo, "bar", "abc");
    foo; // { bar: string; } 
    setProp(foo, "baz", 123);
    foo; // { bar: string; baz: number }
    return foo; // okay
}

That works whether or not you annotate foo as Partial<Foo>. You can see that foo is narrowed from {} to {bar: string} and then to {bar: string; baz: number}, after which it is seen as assignable to Foo and accepted as the return value.

Playground link to code

Upvotes: 4

Related Questions