-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Unions without discriminant properties do not perform excess property checking #20863
Comments
Technically speaking, the expression is a subtype of almost all the types of I thought this was related to #12745 but it seems to still be present. |
Though according to responses to #12745 (comment), this might be working as intended. |
Isn't this due to contextual typing merging all the fields. Why was this behavior chosen?
|
Contextual typing is not relevant here. Neither is type A = { d: Date, e: Date } | { n: number }
const o = { d: new Date(), n: 1 }
const a: A = o Excess property checking of unions does not work because all it does is try to find a single member of the union to check for excess properties. It does this based on discriminant properties, but To get excess property checks for unions without discriminant properties, we could just choose the first union member that has at least one property shared with the source, but that would behave badly with the original example: const options: AllowedOptions = {
startDate: new Date(),
endDate: new Date()
} Here, we'd choose @sylvanaar @DanielRosenwasser Let me know if you have ideas for some other way to get excess property checks for unions without discriminant properties. |
Is this the same issue as #22129? My repro: export interface A {
x?: string;
}
export interface B extends A {
y?: number;
}
export interface C extends A {
z?: boolean;
}
const passes: B | C = { x: 'hello', z: 42 };
const fails: B | C = { z: 42 }; |
Is it possible to sort the union type first by sub-typing, then choose the first matching member? |
@pelotom No, excess property checking only considers property names, not types. @jack-williams That would probably work. Here are some problems that might arise:
(1) is pretty easy to check given a prototype. Figuring out (2) is a precondition for (3). Working through some examples would help here, to show that the results are not surprising to humans. |
Yeah I believe the ordering is only partial, so even if the sort were stable, you'd still have to deal with unrelated elements appear somewhere. Bringing in subtyping seems quite a heavyweight approach given that excess property checking only looks at property names--I don't think that my suggestion was that good, on reflection. I'm wondering whether a solution based on set inclusion of property names would be easier to predict and implement. For instance, use bloom filters to prune union elements that definitely do not contain properties in the initialising object, then use more expensive checks to resolve the remaining cases that might contain all the specified properties. That might involve straight enumeration, or shortcuts like: if a type in the union has no optional properties, then it must have the same bloom filter as the initialiser. |
Another example: declare function f(options: number | { a: { b: number } }): void;
f({ a: { b: 0, c: 1 } }); |
@sandersn the example in #20863 (comment) is excess property checking. When comparing |
@andy-ms I believe your example is essentially the same problem as #13813 (but with union instead). I don't know how to fix it for the general case, but perhaps a special case could be made for unions with a single object type? |
Built-in type guards pretend that TS has excess property checking on unions, when really it doesn't. type Step =
| { name: string }
| {
name: string;
start: Date;
data: Record<string, unknown>;
};
const step: Step = {
name: 'initializing',
start: new Date(),
};
if ('start' in step) {
console.log(step.data.thing);
} EDIT: I was fooled by comment-compression into thinking the type-guard case was not mentioned. Apologies for the repetition. |
@dragomirtitian can you explain what in this example gives the intersection with an empty object? |
Similar behavior here: export interface Parent {
type: "foo" | "bar" | "baz";
}
export interface Bar {
type: "bar";
bar?: number;
}
export interface Baz {
type: "baz";
baz?: number;
}
export type Union = Parent | Bar | Baz;
function test(union: Union) {}
test({
type: "foo"
});
test({
type: "foo",
bar: 1 // ✅ TS(2345): Object literal may only specify known properties, and 'bar' does not exist in type 'Parent'.
});
test({
type: "bar",
bar: 1
});
test({
type: "bar",
bar: 1,
baz: 1 // ❌ No error?
});
test({
type: "bar",
bar: 1,
baz: "1" // ❓ TS(2232): Type 'string' is not assignable to type 'number'.
});
test({
type: "bar",
bar: 1,
baz: 1, // ❌ No error?
unlisted: 1 // ✅ TS(2345): Object literal may only specify known properties, and 'unlisted' does not exist in type 'Union'.
}); Seems as soon as more than 1 element in the union matches, the entire union becomes an intersection? Motivation is to have properties that can be defined for all const options ColumnOptions {
type: "varchar"
}
const options: ColumnOptions {
type: "timestamp",
onUpdateCurrentTimestamp: true // acceptable because timestamp type
}
const options: ColumnOptions {
type: "int"
onUpdateCurrentTimestamp: true // should error but doesnt
} |
I think this might be another example: type A = {
onlyA: boolean;
optionalA?: boolean;
};
type B = {
onlyB: boolean;
optionalB?: boolean;
};
type C = {
onlyC: boolean;
optionalC?: boolean;
};
type Choices = ["A", A] | ["B", B] | ["C", C];
function fn<T extends Choices>(choice: T) {
return choice;
}
fn(["A", { onlyA: true, onlyB: true }]); // Should error but doesn't
fn(["B", { onlyB: true, optionalC: true }]); // Should error but doesn't
const a: A = {
onlyA: true,
onlyB: true, // Errors as expected
};
const b: B = {
onlyB: true,
optionalC: true, // Errors as expected
}; You can also see in this screencapture that TypeScript suggests properties and combinations that shouldn't be allowed: CleanShot.2023-10-17.at.08.55.27.mp4 |
me @ #42384
Is this avoided for performance reasons, or do I not understand something here? |
👋 I'm coming here from #59819 to say that this issue really tripped me up and I was rather surprised that it's been this way since forever. |
TypeScript Version: 2.7.0-dev.201xxxxx
Code
Expected behavior:
An error that
options
cannot be coerced into typeAllowedOptions
becausestartDate
andauthor
cannot appear in the same object.Actual behavior:
It compiles fine.
The text was updated successfully, but these errors were encountered: