microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.02k stars 12.49k forks source link

Object as Interface not asserting properly #42608

Closed timcosta closed 3 years ago

timcosta commented 3 years ago

Bug Report

πŸ”Ž Search Terms

object as interface not asserting properly

πŸ•— Version & Regression Information

Typescript Version: 4.1.3

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

interface ServerRoute {
    path: string;
    method: string;
    options?: object;
}

const workingRouteDefinition: ServerRoute = {
    method: 'get',
    path: '/'
};

// errors due to missing path
const badRouteDefinition: ServerRoute = {
    method: 'get',
    // path: '/'
};

// doesnt error even though there is no path
const weirdRouteDefinition = {
    method: 'get',
    // path: '/'
} as ServerRoute;

// errors due to missing path, but not the extra key
const weirderRouteDefinition = {
    method: 'get',
    // path: '/',
    otherString: 'what'
} as ServerRoute;

Workbench Repro

This is primarily evident when doing something like export default {} as Interface in real code, but can happen when assigning consts as well in the above example case.

πŸ™ Actual behavior

Errors are not thrown where they should be.

πŸ™‚ Expected behavior

Errors due to missing keys should always be present, not just when there's a third unknown key added. Unknown keys should have errors as well.

RyanCavanaugh commented 3 years ago

as will downcast (weirdRouteDefinition) because this is what it's for, but it will not "side-cast" -- the expression type and the asserted type must be in some way related.

timcosta commented 3 years ago

@RyanCavanaugh thanks for getting back to me! I think what's confusing is that adding an extra key to the object changes whether it errors due to a missing path or not. I would (personally) expect TS to consistently assert on the presence/absence of path if the casting is consistently applied. I could understand if adding otherString caused path to stop erroring as the casting changed, but it's weird to me that it causes path to start erroring when it otherwise wasnt.

If there are any docs that explain this feel free to point me that direction and tell me to RTFM - I just haven't found any that explain assertions becoming more strict as unknown keys are added.

RyanCavanaugh commented 3 years ago

This is described here: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions

and here

https://www.typescriptlang.org/docs/handbook/type-compatibility.html

timcosta commented 3 years ago

Okay, so I still think there's weird behavior happening. I'm going to try to explain using phrases from the documentation that you've so kindly linked.

The basic rule for TypeScript’s structural type system is that x is compatible with y if y has at least the same members as x.

Great, this absolutely make sense. All members of x must be present in y for a type to be considered compatible. This lines up with the behavior seen below in my first two examples:

const workingRouteDefinition: ServerRoute = {
    method: 'get',
    path: '/'
};

const badRouteDefinition: ServerRoute = {
    method: 'get',
};

workingRouteDefinition contains every member of ServerRoute, so this instantiation passes type checking. badRouteDefinition does not contain every member of ServerRoute, so this explodes during type checking.

Now we get to the cases that use type assertions rather than directly assigning types like the examples above do.

const weirdRouteDefinition = {
    method: 'get',
} as ServerRoute;

This weirdRouteDefinition unexpectedly passes the type assertion check. The docs from the type assertion section in v2 of the handbook states the following:

you can use a type assertion to specify a more specific type

TypeScript only allows type assertions which convert to a more specific or less specific version of a type

As far as I can tell, ServerRoute is a more specific version of the inferred type object, so this assertion is allowed by the compiler.

What doesn't make sense here is why a more specific type assertion is failing to error due to missing the path key.

If we then look at the final example, we'll see that the more specific assertion starts working, but only after a key is added that isn't present in the more specific assertion.

const weirderRouteDefinition = {
    method: 'get',
    otherString: 'what'
} as ServerRoute;

The above snippet will start throwing errors about missing path even though it seems like ServerRoute is actually a less specific type assertion due to the additional key.

In both of these examples, the number of overlaps between the type asserted object and the type is the same single member. I'm not sure why TS is considering one of these objects to be missing a path key while the other isn't missing the path key when the number of overlapping members between the two objects and the type they are being asserting on is the same.

I dont see anything in the documentation that explains this behavior, I actually feel as though the documentation states the the behavior should be the inverse.

The first weird example should error due to a missing path as the type being asserted against has more members than the object being checked which fails the "y has at least the same members as x" check from the type compatibility docs.

The second weirder example I gave should also error due to missing the path key, like it is currently doing.

The following example I would expect to pass type checking, as it passes the type compatibility "y has at least the same members as x" check outlined in the docs, and it is a "less specific" type that is being asserted against.

const extendedRouteDefinition = {
    method: 'get',
    path: '/test',
    otherString: 'what'
} as ServerRoute;

If this can't convince you that there's inconsistent behavior here when compared to the docs I'll stop pestering you, but everything you cited seems to state that the behavior I'm experiencing is unexpected due to type compatibility checking and the fact that in the problematic scenario I'm specifying a "more specific" version of a type and en error isn't being thrown for a missing key.

RyanCavanaugh commented 3 years ago

This behavior is 100% consistent with the definition of an allowable type assertion - that the expression type and the asserted type are comparable (which is the same as "assignable to or from" for the cases shown here). Every example here can be shown to be correctly following that rule.

You seem to expect that type assertions should not be capable of downcasting, but downcasting is the primary use case of type assertions, which I think indicates some fundamental misapprehensions about what's supposed to be happening here.

timcosta commented 3 years ago

Thanks for the explanations Ryan. Much appreciated.