Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript intersection type and function signature not throwing expected error

Tags:

typescript

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?

like image 465
Fmrubio Avatar asked Jan 28 '23 18:01

Fmrubio


2 Answers

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.

like image 110
Sefe Avatar answered Jan 31 '23 22:01

Sefe


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
like image 45
artem Avatar answered Jan 31 '23 22:01

artem