Object types in TypeScript are, generally speaking, open and extendible and allow values with extra properties. They are not closed, sealed, or "exact" as requested in microsoft/TypeScript#12936. This openness is often quite useful and allows interface
hierarchies and class
hierarchies to form type hierarchies. It's nice that sub/super-interfaces and sub/super-classes are also sub/super-types and it would be annoying if you couldn't add properties in sub-interfaces or sub-classes without breaking the types.
So, for example, the empty type {}
doesn't mean "an object with no allowed properties"; it means "an object with no known properties". So {}
will accept just about anything, (even primitive types since they are autoboxed), except for null
and undefined
.
(To complicate matters, TypeScript performs excess property checking on object literals, for reasons largely unrelated to type safety. So const a: {x: number} = {x: 0, y: 1}
will give you an error, but const b = {x: 0, y: 1}; const a: {x: number} = b;
will not give you an error. There's also weak type detection which does similar things for types with all-optional properties.)
So, technically, to implement what you're asking for, the language would need support for exact types. Until and unless that happens, we have to try to simulate or approximate it.
In general, the closest we can get to "an object type without extra properties" is "an object type without a specific set of properties". Well, even that is not quite possible. The closest you can get is more like "an object type where each property in this specific set is either missing or present-but-undefined
". This can be done with a mapped type that takes all the keys K
you want to prohibit and makes them optional properties of the impossible never
type, like
type ProhibitKeys<K extends PropertyKey> = { [P in K]?: never }
So given
type Test = ProhibitKeys<keyof B>;
/* type Test = {
b1?: undefined;
b2?: undefined;
} */
we have a type that prohibits any defined property at the b1
or b2
keys.
Back to Maybe<T>
then. Instead of T | {}
, we can say T | ProhibitKeys<keyof T>
:
type Maybe<T> = T | ProhibitKeys<keyof T>;
So Maybe<T>
either has all of the (required) properties of T
, or none of the properties of T
. So now if we define S
as
type S = Maybe<A> & Maybe<B>;
and inspect its structure using a technique described in How can I see the full expanded contract of a Typescript type? :
type Expand<T> = T extends unknown ?
{ [K in keyof T]: T[K] } : never;
type ExpandS = Expand<S>;
/* type ExpandS =
{ readonly a1: 1; readonly b1: 1; readonly b2: 2; } |
{ readonly a1: 1; b1?: undefined; b2?: undefined; } |
{ a1?: undefined; readonly b1: 1; readonly b2: 2; } |
{ a1?: undefined; b1?: undefined; b2?: undefined; } */
You can see that S
has the four valid combinations of properties, and results in this behavior:
const value1: S = {} // okay, neither A nor B
const value2: S = { a1: 1 } // okay, A
const value3: S = { b1: 1, b2: 2 } // okay, B
const value4: S = { a1: 1, b1: 1, b2: 2 } // okay, both A and B
const value6: S = { a1: 2 } // error, this is a broken A
const value7: S = { a1: 1, b1: 1 }; // error, okay A but broken B
Those all behave the way you want, I think. You also get the desired error here:
const value5: S = { d: 1 } // error
but this is because of weak type and excess property detection, and is fragile. You might be surprised with something like
const value5a = { d: 1, x: 2 };
const value5b: S & { x: number } = value5a; // no error
which defeats both weak type detection (because x
is required) and excess property checking (because value5a
is not an object literal). But this is really the best we can do; when it comes down to it, TypeScript does not have exact types, so approximations like this should hopefully suffice.
Playground link to code