4

I have declared the following types:

type ExampleA = {
    a: string;
}

type ExampleB = {
    b: number;
}

type ExampleC = {
    c: boolean;
}

type Examples = ExampleA &
    ExampleB &
    ExampleC;

Then I use the type as follows:

function foo(pattern: { [key: string]: string }) {
    console.log(pattern);
}

const bar: Examples = { a: 'foo', b: 1, c: false }; 
foo(bar);

The typescript compiler is not throwing any error in the invocation of the foo(bar) method, even though bar:Examples variable doesn't match the function signature of foo.

Playground link

Why is typescript not throwing any error? Is this a bug in the compiler?

Fmrubio
  • 346
  • 1
  • 4
  • 13
  • Not 100% sure, but looks like a bug to me. I think the compiler widens the type of `ExampleA` to match `{ [key: string]: string }` – Titian Cernicova-Dragomir Jul 13 '18 at 11:43
  • Yes, I agree. In fact, you can define `foo` as `function foo(pattern: { [key: string]: string }) { const c = pattern['c']; console.log(c); }` and it still doesn't give an error, though in the function, `c` is recognised as `string`, something that won't be true if you pass `Examples` as an argument. – Oscar Paz Jul 13 '18 at 11:46
  • @Fmrubio Will you file the bug? I can do it if you want. If you do file the bug please post the link here – Titian Cernicova-Dragomir Jul 13 '18 at 12:05

2 Answers2

5

The reason why this works is the assignability of an intersection type to its base types.

As an intersection type, Examples is assignable to ExampleA. ExampleA is assignable to { [key: string]: string }. Therefore, Examples must be assignable to the function parameter type

This can be shown in this code:

const bar: Examples = { a: 'foo', b: 1, c: false }; 
const bar2: ExampleA = bar;
const bar3: { [key: string]: string } = bar2;
foo(bar3); //This works
foo(bar2); //Since the assignment bar3 = bar2 works, this must work, too
foo(bar); //Since the assignment bar2 = bar works, this must work, too

Playground version


UPDATE

The behavior is consequential when you want to uphold the principle "when A is assignable to B and B is assignable to C, then A must be assignable to C". The type system has no other choice than to allow these kind of assignents. However, there actually is another issue in passing the value as a parameter to foo.

You can assign a value to a variable of a type that is sharing only a part of the members of the assigned value. So this assignment works fine:

let item: { a: string, b: number } = { a: "Hello World!", b: 1 };
let partiallyMatchingItem: { a: string } = item;

It is absolutely no problem that partiallyMatchingItem has more properties than actually declared in the type. The guarantee is a minimum guarantee.

The assignment to a mapped type however does not work, because item's additional member of type number:

let item = { a: "Hello World!", b: 1 };
let mappedTypeItem: { [key: string]: string } = item; //Error

So the guarante this time is not a minimum guarantee, it is an absolute guarantee. And that is quite ridiculous, when you consider how easily you can get around it (intentionally or accidentally):

let item = { a: "Hello World!", b: 1 };
let partiallyMatchingItem: { a: string } = item;
let mappedTypeItem: { [key: string]: string } = partiallyMatchingItem;

Or simply:

let item = { a: "Hello World!", b: 1 };
let mappedTypeItem: { [key: string]: string } = item as { a: string };

This is an error waiting to happen, especially when you enumerate through the properties of mappedTypeItem and you assume that the values of all properties are a string.

Considering how common structurally typed assignments are in TypeScript, this absolute guarantee does not fit into the system of minimum guarantees generally provided by the type system.

A clean solution would be to make values of "regular" types not assignable to mapped types (if backwards compatibility is required, you could toggle it with a switch in the tsconfig.json file). At least you should probably avoid these kind of assignments since the type safety provided here is quite weak.

Pierre Fourgeaud
  • 14,290
  • 1
  • 38
  • 62
Sefe
  • 13,731
  • 5
  • 42
  • 55
  • Not true, the type means **all** field bus be of type string This gives an error and in theory the type of `boo` should be the same as `Example`: `const bar : { a: 'foo', b: 1, c: false }; foo(bar);` – Titian Cernicova-Dragomir Jul 13 '18 at 11:52
  • 1
    This is really, really, really, wrong. Try invoking your own examples with simple objects that do not have all fields as the return value of the index, it will give an error. – Titian Cernicova-Dragomir Jul 13 '18 at 11:55
  • 1
    Counter example: http://www.typescriptlang.org/play/#src=function%20bat(pattern%3A%20%7B%20%5Bkey%3A%20string%5D%3A%20number%20%7D)%20%7B%7D%0D%0Afunction%20baz(pattern%3A%20%7B%20%5Bkey%3A%20string%5D%3A%20boolean%20%7D)%20%7B%7D%0D%0Alet%20arg%20%3D%20%7B%20a%3A%201%2C%20b%3A%20true%7D%0D%0Abat(arg)%3B%0D%0Abaz(arg)%3B – Titian Cernicova-Dragomir Jul 13 '18 at 11:56
  • @TitianCernicova-Dragomir: I stand by my result, but I have edited the answer in terms of the explanation. The result is absolutely not wrong, as it preserves language consistency. – Sefe Jul 13 '18 at 12:16
  • Still don't agree with your reasoning. Yes you can assign a type with more properties to a type with fewer properties (`bar2= bar`) and then assign it to a type with an indexer (`bar3 = bar2`) but you still should not be able to assign it directly, and even in your example this fails (as it should) `const bar4: { [key: string]: string } = bar;` if bar is typed implicitly (`const bar: Examples = { a: 'foo', b: 1, c: false }; `) – Titian Cernicova-Dragomir Jul 13 '18 at 12:23
  • Structurally `Examples` and `{ a: string, b: number, c: boolean }` are the same yet they behave very differently when assigned to indexes. This is a bug it should not be this way. – Titian Cernicova-Dragomir Jul 13 '18 at 12:25
  • @TitianCernicova-Dragomir: *If bar is typed implicitly*. It is however *not* typed implicitly. The implicit type is *not* `Example`, hence it is *not* assignable to `ExampleA`, whatever structural compatibility they have. The additional relation of `Examples` to `ExampleA` derived from the type intersection is not present in the structurally compatible implicit type. – Sefe Jul 13 '18 at 12:29
  • @Sefe Thanks for the reply. I agree that your replied explain the current implementation, however, the point of using intersection is to quote"combines multiple types into one". I am expecting that the intersection type `Examples` to include all types from `ExampleA`, `ExampleB` and `ExampleC`, therefore, to get an error on the invocation of `foo(bar)`. Docs: https://www.typescriptlang.org/docs/handbook/advanced-types.html @TitianCernicova-Dragomir I'll file a bug and share the link here – Fmrubio Jul 13 '18 at 12:32
  • Actually if the variable were to be implicitly type it **would** be assignable to `ExampleA`, since it is structurally compatible: `const bar = { a: 'foo', b: 1, c: false }; const bar2: ExampleA = bar;` – Titian Cernicova-Dragomir Jul 13 '18 at 12:32
  • @TitianCernicova-Dragomir: How does the assignablity to `ExampleA` invalidate my argument? `foo(bar2)` still works. The only thing that doesn't work in this case is `foo(bar)` which is within my argument, since the assignability can not be derived from structural compatibility *alone*. It is derived from structural compatibility *and* the **additional** assignability of an intersection type. You assume that implicit typing should be always equivalent to explicit typing. I don't see a reason why this should be the case. – Sefe Jul 13 '18 at 12:41
  • Ok, I guess we'll have to agree to disagree. My assumption is that `{ a: string } & { b: boolean }` should behave the same as `{ a: string, b: boolean }` when assigning to `{ [s:string]: string }` which it does not. I see no reasonable justification for the different behavior. We will see what the compiler team thinks when @Fmrubio posts the bug :) – Titian Cernicova-Dragomir Jul 13 '18 at 12:45
  • 1
    @Fmrubio: If you consider this a bug, you have to accept the breaking change that intersection types are no longer assignable to any of their base types. And this can become a real problem when you intersect interfaces where additional members are very common. There is no other way without breaking the basic principle of "when A is assignable to B and B is assignable to C, then A *must* be assignable to C". – Sefe Jul 13 '18 at 12:49
  • @Sefe I have been thinking about your replies and I see your point. Could you include your previous comment in the reply? Then I'll accept your reply as the right answer for my question. I wonder if you could provide an alternative solution for the intention of my code, where I have a combination of types into one but the invocation of the function throws an error since the combined type doesn't match with the function signature. – Fmrubio Jul 13 '18 at 13:14
  • 1
    I found a comment confirming this answer: [One of the type rules is that `T` & `U` is assignable to `X` if `T` **or** `U` is assignable to `X`](https://github.com/Microsoft/TypeScript/issues/24970#issuecomment-397448205). Also, [this issue](https://github.com/Microsoft/TypeScript/issues/13986) confirms that intersection of two types is different from an object type that has properties from both types. – artem Jul 13 '18 at 16:59
  • @Fmrubio: I have made an update to the answer where I have also added the gist of my last comment. There is also so extension why the assignment is stillö problematic. – Sefe Jul 14 '18 at 20:58
4

If you really want to have the error, you can declare Example as interface, not intersection type. Since 2.2, interface can extend object type (or even intersection type)

type ExampleA = {
    a: string;
}

type ExampleB = {
    b: number;
}

type ExampleC = {
    c: boolean;
}

interface Examples extends ExampleA, ExampleB, ExampleC {

}

function foo(pattern: { [key: string]: string }) {
    console.log(pattern);
}

const bar: Examples = { a: 'foo', b: 1, c: false }; 

foo(bar); // error

Or even this way, to better illustrate the difference between interface and intersection types:

type Examples = ExampleA & // the same as in question
    ExampleB &
    ExampleC;

interface IExamples extends Examples { // empty interface "collapses" the intersection 
}

const bar1: Examples = { a: 'foo', b: 1, c: false };  
foo(bar1); // no error

const bar2: IExamples = { a: 'foo', b: 1, c: false };  
foo(bar2); // error

One more way to construct object type out of the intersection, as suggested by Titian in the comment, is to use mapped type which is almost, but not quite, identical to its generic parameter:

type Id<T> = { [P in keyof T]: T[P] } 

const bar3: Id<ExampleA & ExampleB & ExampleC> = { a: 'foo', b: 1, c: false };

foo(bar3); // error
artem
  • 46,476
  • 8
  • 74
  • 78