Spread operator and optional fields. How to infer proper type

Viewed 8111

Consider you have an object with a non-nullable required field:

interface MyTypeRequired { 
    value: number;
}

And you want to update it with fields of another object, with an optional field:

interface MyTypeOptional { 
    value?: number;
}

So you go ahead and create a function:

function createObject(a: MyTypeRequired, b: MyTypeOptional) {
    return { ...a, ...b };
}

What would be the inferred return type of this function?

const a = createObject({ value: 1 }, { value: undefined });

Experimentation suggests it would conform to the MyTypeRequired interface, even though the second spread has an optional field.

If we change the order, the inferred type doesn't change, even though the runtime type would be different.

function createObject(a: MyTypeRequired, b: MyTypeOptional) {
    return { ...b, ...a };
}

Why does TypeScript have such behavior and how to workaround this issue?

3 Answers

Object spread types are resolved as follows:

Call and construct signatures are stripped, only non-method properties are preserved, and for properties with the same name, the type of the rightmost property is used.

Object literals with generic spread expressions now produce intersection types, similar to the Object.assign function and JSX literals. For example:

This works well, until you have an optional property for the rightmost argument. {value?: number; } can either mean 1) a missing property or 2) a property with undefined value - TS cannot distinguish those two cases with the optional modifier ? notation. Let's take an example:

const t1: { a: number } = { a: 3 }
const u1: { a?: string } = { a: undefined }

const spread1 = { ...u1 } // { a?: string | undefined; }
const spread2 = { ...t1, ...u1 } // { a: string | number; }
const spread3 = { ...u1, ...t1 } // { a: number; }
spread1

makes sense, the property key a can be set or not set. We have no other choice than expressing latter with a undefined type.

spread2

a's type is determined by the rightmost argument. So if a in u1 exists, it would be string, otherwise the spread operator would just take a from the first argument t1 with type number. So string | number also makes kinda sense. Note: There is no undefined here. TS assumes, the property doesn't exist at all, or it is a string. The result would be different, if we give a an explicit property value type of undefined:

const u2 = { a: undefined }
const spread4 = { ...t1, ...u2 } // { a: undefined; }
spread3

The last one is simple: a from t1 overwrites a from u1, so we get number back.


I wouldn't bet on a fix, so here is a possible workaround with a separate Spread type and function:

type Spread<L, R> = Pick<L, Exclude<keyof L, keyof R>> & R;

function spread<T, U>(a: T, b: U): Spread<T, U> {
    return { ...a, ...b };
}

const t1_: { a: number; b: string }
const t2_: { a?: number; b: string }
const u1_: { a: number; c: boolean }
const u2_: { a?: number; c: boolean }

const t1u2 = spread(t1_, u2_); // { b: string; a?: number | undefined; c: boolean; }
const t2u1 = spread(t2_, u1_); // { b: string; a: number; c: boolean; }

Hope, it makes sense! Here is sample for above code.

Warning! This is my interpretation (theory) of what happens. It isn't confirmed.


The issue seems to be an intended behavior of handling optional fields.

When you define this type:

interface MyTypeOptional { 
    value?: number;
}

TypeScript expects that the value would be of type number | never.

So when you spread it

const withOptional: MyTypeOptional = {};
const spread = { ...withOptional };

The type of spread object should be either { value: never | number } or { value?: number }.

Unfortunately, due to the ambiguous usage of optional field vs undefined TypeScript infers the spread object as such:

value?: number | undefined

So the optional field type is cast into number | undefined when spreading.

What doesn't make sense is that when we spread a non-optional type it seems to override the optional type:

interface MyTypeNonOptional { 
    value: number;
}
const nonOptional = { value: 1 };
const spread2 = { ...nonOptional, ...withOptional }

Looks like a bug? But no, here TypeScript doesn't cast optional field into number | undefined. Here it casts it to number | never.

We can confirm this by changing optional into explicit |undefined:

interface MyTypeOptional { 
    value: number | undefined;
}

Here, the following spread:

const withOptional: MyTypeOptional = {} as MyTypeOptional;
const nonOptional = { value: 1 };
const spread2 = { ...nonOptional, ...withOptional }

would be inferred as { value: number | undefined; }

And when we change it to optional or undefined:

interface MyTypeOptional { 
    value?: number | undefined;
}

it is broken again and inferred as { value: number }


So what's a workaround?

I would say, don't use optional fields until TypeScript team fixes them.

This has several implications:

  • If you need the key of the object to be omitted if undefined, use omitUndefined(object)
  • If you need to use Partial, limit its usage by making a factory: <T>createPartial (o: Partial<T>) => Undefinable<T>. So that the result of createPartial<MyTypeNonOptional>({}) would be inferred as { value: undefined } or Undefinable<MyTypeNonOptional>.
  • If you need a value of a field to be unset, use null for that.

There's an open issue about this limitation of optional fields: https://github.com/microsoft/TypeScript/issues/13195

Workaround can be done by type level function. Consider:

interface MyTypeRequired { 
    a: number;
    value: number;
}

interface MyTypeOptional { 
    b: string;
    value?: number;
}

type MergedWithOptional<T1, T2> = {
    [K in keyof T1]: K extends keyof T2 ? T2[K] extends undefined ? undefined : T2[K] : T1[K] 
} & {
    [K in Exclude<keyof T2, keyof T1>]: T2[K]
}

function createObject(a: MyTypeRequired, b: MyTypeOptional): MergedWithOptional<MyTypeRequired, MyTypeOptional> {
    return { ...b, ...a };
}

I have added additional fields in order to check the behavior. What I am doing is, when the second object has optional field (possible undefined) then consider that by adding this to the union, so the result will be T1[K] | undefined. The second part is just merging all other fields which are in T2 but not in T1.

More details:

  • K extends keyof T2 ? T2[K] extends undefined ? undefined : T2[K] : T1[K] if second object has this key and its value can be undefined then append undefined to the value type, and it is appended and not replaced because this is how conditional types behaves for union types
  • Exclude<keyof T2, keyof T1> - use only keys which are not in the first object
Related