Object types in TypeScript do not in general prohibit extra properties. They are "open" or "extendible", as opposed to "closed" or "exact" (see microsoft/TypeScript#12936). Otherwise it would be impossible to use subclasses or interface extensions:
interface FooWidget extends Widget {
foo: string;
}
const f: FooWidget = { creationTime: 123, foo: "baz" };
const w: Widget = f; // okay
Sometimes people want such "exact" types, but they're not really part of the language. Instead, what TypeScript has is excess property checking, which only happens in very particular circumstances: when a "fresh" object literal is given a type that doesn't know about some of the properties in the object literal:
const x: Widget = { creationTime: 123, foo: "baz" }; // error, what's foo
An object literal is "fresh" if it hasn't been assigned to any type yet. The only difference between x
and w
is that in x
the literal is "fresh" and excess properties are forbidden, while in w
the literal is... uh... "stale" because it has already been given the type FooWidget
.
From that it might seem that your widgetFactory
should give an error, since you are returning the object literal without assigning it anywhere. Unfortunately, freshness is lost in this case. There's a longstanding issue, microsoft/TypeScript#12632, that notes this, and depends on a very old issue, microsoft/TypeScript#241. TypeScript automatically widens the returned type when checking to see if it's compatible with the expected return type... and freshness is lost. It looks like nobody likes this, but it's hard to fix it without breaking other things. So for now, it is what it is.
You already have one workaround: explicitly annotate the function's return type. This isn't particularly satisfying, but it gets the job done.
export const WidgetFactory1: Factory<Widget> = {
build: (): Widget => {
return {
creationTime: Date.now(),
foo: 'bar', // error!
};
},
};
Other workarounds involving trying to force the compiler to compute exact types are possible but significantly uglier than what you're doing:
const exactWidgetFactory =
<W extends Widget & Record<Exclude<keyof W, keyof Widget>, never>>(
w: Factory<W>) => w;
export const WidgetFactory2 = exactWidgetFactory({
build: () => { // error!
// ~~~~~ <-- types of property foo are incompatible
return {
creationTime: Date.now(),
foo: 'bar',
};
},
});
So I'd suggest just continuing with what you've got there.
Okay, hope that helps; good luck!
Playground link to code