Typescript: Object.assign not type checking

Created on 23 Sep 2016  ·  27Comments  ·  Source: microsoft/TypeScript

TS 2.0.3

Given:

(() => {
    type State = { foo: string };
    const state: State = { foo: 'bar' };
    const newState: State = Object.assign({}, state, { foo: 1 }) // I expect this to error
})();

I swear this used to work!

Duplicate Suggestion

Most helpful comment

I am close to having a PR for spread types, so when those are in, Object.assign will change to have the correct type:

assign<T, U>(target: T, source: U): { ...T, ...U }

Right now I am working in the branch object-rest-spread-WIP if you want to follow along.

All 27 comments

As I explained on IRC, the result of assign is { foo: string & number }, so TS considers it okay to be assigned to { foo: string }.

I am close to having a PR for spread types, so when those are in, Object.assign will change to have the correct type:

assign<T, U>(target: T, source: U): { ...T, ...U }

Right now I am working in the branch object-rest-spread-WIP if you want to follow along.

(also you'll just be able to use the syntax { ...state, foo: 1 } instead of calling Object.assign)

@sandersn So with your change, the result of assign would be { foo: number } ?

Yes, which is not assignable to State, so it would error as desired.

Thanks, that's great @sandersn. I look forward to it!

@Arnavion How come this works?

const someState: { foo: string } & { foo: number } = { foo: 1 }

I think you explained why in IRC, but I'm not sure I completely understand. Are you saying in the above example we're trying to assign number to string & number which is an error, but if we try to assign string & number to number then it is allowed?

DerivedClass is assignable to BaseClass because all DerivedClass are also BaseClass. Similarly string & number is assignable to number because all string & number are also number.

BaseClass is not assignable to DerivedClass because an arbitrary BaseClass is not necessarily also a DerivedClass. Similarly number is not assignable to string & number because an arbitrary number is not necessarily also a string.

That makes sense, thank you @Arnavion:

    // number is not assignable to string & number because an arbitrary number is not necessarily also a string.
    const x = 'foo' as string & number
    const x1: number = x
    // string & number is assignable to number because all string & number are also number.
    const x2: string & number = 'foo'

@sandersn, this looks very good! Shouldn't the new type of Object.assign be:

assign<T, U>(target: T, source: U): T & { ...U }

i.e. all type features of target are preserved, with only properties from U tacked on. This covers the very common use case of adding properties to functions.

@danielearwicker that's not quite right either because if the function already has a property that source overwrites then intersection doesn't give the right answer either. If we take as given that our only operators are spread and intersection, then I'd go with the stricter and more accurate one. (Of course we could introduce two kinds of spread but I don't like that idea.)

Can you point me to some source that spreads properties into functions? I'm always interested in collecting uses of the spread type — previously I thought that real-world uses were always with property bags.

Yes, I see. I haven't been using object spread operator for this purpose (I only use TS) but I have been using Object.assign and similar functions to do the same thing.

Hiding in plain sight, jQuery's $ is probably the famous example of a function with properties. It's very handy to be able to encapsulate some capabilities in an object that is also directly callable, giving succinct access to one preferred way of using it.

I've been working on a strongly-typed composable Redux library where I make augmented functions this way. Although as you can see, I've simply defined my own assign function so I can control the type signature anyway, so I can keep using intersection and you can ignore me! :)

We are looking for a new type construct (mapped types) to address this instead of the spread and rest operators in the type space. we should have updates on this in the next few days.

I made #12110. This patch makes possible to declare and constrain the parameters 2nd and above.

-    assign<T, U>(target: T, source: U): T & U;
+    assign<T, U>(target: T, source: U, ...sources: U[]): T & U;
(() => {
  type State = { foo: string };
  const state: State = { foo: 'bar' };
  const newState: State = Object.assign<{}, State>({}, state, { foo: 1 }) // error
})();

Any updates on this now 2.1 is shipped?

https://github.com/Microsoft/TypeScript/issues/10727 tracks adding a spread/rest type operator support. Implementing https://github.com/Microsoft/TypeScript/issues/10727 is a prerequisite for this one.

I made the patch #13611 used Partial types.
@sandersn I think we need to use both partial types and spread types because spread/rest types don't support rest parameters as you changed signatures in #13288.

Now I'm using the following signatures.

export function assign<T extends U, U extends object>(target: T, ...sources: Partial<U>[]): T;
export function assign<T extends U, U extends object>(target: object, source: T, ...sources: Partial<U>[]): T;
export function assign<T extends object>(target: T, ...sources: Partial<T>[]): T;
export function assign<T extends object>(target: object, source: T, ...sources: Partial<T>[]): T;

Subscribing for updates.

What is the current status on this?

@metodribic waiting on a spread type, which is waiting on a difference type to make sure JSX Higher-order-component scenarios work. Now that some kind of difference types are expressible using conditional types, the scenario may work. The conversation is happening at #10727.

This type definition of Object.assign can fixed in now using utility-types's assign definition https://github.com/piotrwitek/utility-types#assign (maybe steal implementations), but we should wait for type spreading features?

@airtoxin would utility-type's Assign get this example right?

type O = {
  x: number
  y?: number | undefined
  m: () => number
}
declare class C {
  x: string
  y: string
  m2: () => string
}
// now Assign<C, O> should be
type Result = {
  x: number
  y: number | string
  m: () => number
}

Currently it produces

{
  x: number
  y?: number | undefined
  m: () => number
  m2: () => string
}

Meaning that it isn't correctly combining optional properties from the right, and isn't correctly discarding class-originating methods, which are on the prototype and will be discarded by Object.assign at runtime.

@sandersn would your work also fix this example?

(Reprinting example for ease of reading)

const a: { b: number } = { b: 123 };
Object.assign(a, { c: 456 });
a.c = 456;

The problem with that example is that we don't model Object.assign's mutation of its first argument. That's not possible in Typescript today, since we'll just use the declared type of a all the way through the program. The control flow graph could allow us to model mutations to a type, but right now we only narrow types by removing constituents of a union type. And of course a doesn't have a union type.

A workaround could be:

(() => {
    type State = { foo: string };
    const state: State = { foo: 'bar' };
    const assignedValues: State = { foo: 1 }; //complain expected
    const newState: State = Object.assign({}, state, assignedValues) 
})();

The trade-off being an extra line of code for type safety.

Tracking at #10727

Was this page helpful?
0 / 5 - 0 ratings