ken
ken

Reputation: 9013

Detecting when Partial<T> extends T

I have a use-case where I'm building to a target "state":

type State = { foo: number, bar: number, baz?: string };

When I start, i most likely haven't achieved the full State but rather am happy to fit into the shape of Partial<State>. What I want to be able to do, how is infer when I've reached fabled State state.

To help me achieve this goal, I first created a utility type called Resolve:

type Resolve<T extends Partial<State>> = 
    T extends infer U 
        ? U extends State ? State : Partial<State> 
        : never;

This utility works when it is given inferred types like this:

const implicit1 = { foo: 5 };
const implicit2 = { foo: 5, bar: 10 };
// YAY: resolves to Partial<State>
const implicitResolve1: Resolve<typeof implicit1> = implicit1;
// YAY: resolves to State
const implicitResolve2: Resolve<typeof implicit2> = implicit2;

However, once a type has been expressed as a Partial<State> it refuses to infer that it might be of type State:

const explicit1: Partial<State> = { foo: 5 };
const explicit2: Partial<State> = { foo: 5, bar: 10 };
// YAY: correctly resolves to Partial<State>
const explicitResolve1: Resolve<typeof explicit1> = explicit1;
// SAD FACE: also resolves to Partial<State> even though the intent 
// was for it to be recognized that it is a valid State!
const explicitResolve2: Resolve<typeof explicit2> = explicit2;

In my broader solution I had a type guard already waiting in the wings and I figured that would give me the super powers I'd need:

type TypeGuard<T> = (thing: unknown) => thing is T;
const tg: TypeGuard<State> = (thing: unknown): thing is State => {
    return typeof thing === "object" 
      && typeof (thing as any)?.foo === "number" 
      && typeof (thing as any)?.bar === "number";
};
function SuperResolve(thing: unknown, tg: TypeGuard<State>) {
    return tg(thing) ? thing as State : thing as Partial<State>
}
// SHOCKED: this resolves to Partial<State> too!
const fnResolve = SuperResolve(explicit2, tg);

I'm now at the end of my abilities ... surely there is some way to detect when a Partial<T> has reached <T>.

Code Playground

Upvotes: 2

Views: 935

Answers (2)

jcalz
jcalz

Reputation: 328362

If you annotate a variable to be a single object type like Partial<State> (equivalent to { foo?: number, bar?: number, baz?: string }), the TypeScript compiler will not narrow the apparent type of the variable upon assigning a more specifically-typed value to it.

Such control-flow-based narrowing only happens to variables/properties of union types, and Partial<State> is not itself a union type.

So, as soon as you write

const x: Partial<State> = { foo: 5, bar: 10 };

you have discarded any information the compiler might have had about the particular value you assigned to x. The variable x is of type Partial<State> no matter what you assign to it. You could use a user-defined type guard and control flow analysis to conditionally narrow it, but this has nothing to do with the actual value assigned to x:

if (tg(x)) {
    x.bar.toFixed(2); // okay
} 

If you want the compiler to remember that x is assignable to State, you should not preemptively widen it to Partial<State>. Just let the compiler infer the type of x:

const x = { foo: 5, bar: 10 }; // okay

Anything that requires a State or a Partial<State> will be happy to accept x due to TypeScript's structural type system:

function takeState(state: State) { }
takeState(x); // okay

If you want to ensure that x is really a Partial<State> upon assignment and not only catch an error later, you could use a helper function like

const asPartialState = <T extends Partial<State>>(t: T) => t;

And verify that it checks the type of its input against Partial<State> without widening it to Partial<State>:

const y = asPartialState({ foo: 5, bar: 10 }); // okay
takeState(y); // okay

const z = asPartialState({ foo: 5, bar: "oops", baz: "" }); // error!
// ------------------------------> ~~~
// Type 'string' is not assignable to type 'number | undefined'.

None of this would allow you to do any "building" of a Partial<State> into State, since the type of a variable does not mutate upon assignments (I don't see any such building in your question either, so I'm not sure if that is in or out of scope. Assuming it's in scope.)

So, this won't work:

const x = { foo: 5 }
x.bar = 10; // error!
takeState(x); // error!

const y: Partial<State> = { foo: 5 }
y.bar = 10; // okay
takeState(y); // error!

If you are going to do this building by manually assigning each property, you can use something like an assertion function instead to get control-flow narrowing:

function setProp<T extends object, K extends PropertyKey, V>(
    obj: T, key: K, val: V
): asserts obj is T & Record<K, V> { (obj as any)[key] = val }

const x = { foo: 5 }
setProp(x, "bar", 10); // okay now
takeState(x); // okay

If you are hoping to do some more complex or abstract "building" where you loop over properties or pass things to other functions which are not themselves assertion functions, even this will not work:

const x = {};
(["foo", "bar"] as const).forEach(k => setProp(x, k, 5));
takeState(x); // error!

In such cases, you should just give up on having the compiler try to follow the relatively complicated control flow that proves when your Partial<State> has grown into a State, and just assert that it's been done (with possible loss of type safety):

takeState(x as State); // no error, but maybe you're wrong

or do an unnecessary runtime check with your type guard function:

if (!tg(x)) throw new Error("My life is a lie"); 
takeState(x); // okay

Playground link to code

Upvotes: 2

GBF_Gabriel
GBF_Gabriel

Reputation: 2656

State is a complete subset of Partial<State>, that is why State | Partial<State> == Partial<State>.

The only way you can coerce the Partial<State> into State is by explicit setting Required<State> or, using type-fest and setting SetRequired<Partial<State>, "foo" | "bar"> (but this one implies that you can extract the keys from somewhere).

The following code:

const explicit2: Partial<State> = { foo: 5, bar: 10 };
const explicitResolve2: Resolve<typeof explicit2> = explicit2;

is an error because you stripped all the information regarding obligations when you set : Partial<State>, it doesn't matter what exists after the =.

I suggest you get two Type Guards to assert the types you need. One that can tell if the argument is a Partial<State> and another to tell if it is an State, and run them in different if statements.

Upvotes: 0

Related Questions