3

I am trying to construct a type S which is a (partial) sum of a discrete number of interfaces (in the example below, A and B) i.e. satisfies {} | A | B | (A & B).

I've attempted the following (with expected values) which does not do what I would expect (most likely because I don't know how to represent an empty interface other than {}, which I believe is not really the case).

type A = {
    readonly a1: 1
}

type B = {
    readonly b1: 1
    readonly b2: 2
}

type Maybe<T> = T | {}

type S =  Maybe<A> & Maybe<B>
// algebraic type: C = ({} & {}) | (A & {}) | (A & B) | ({} & B)

const value1: S = {} // should type-check (value1 contains neither A nor B)
const value2: S = {a1: 1} // should type-check (value2 contains A)
const value3: S = {b1: 1, b2: 2} // should type-check (value3 contains B)
const value4: S = {a1: 1, b1: 1, b2: 2} // should type-check (value4 contains A and B)
const value5: S = {d: 1} // ideally, should NOT type-check (`d` is an extraneous key)
const value6: S = {a1: 2} // should NOT type-check (value6 does not satisfy A)

Can this be achieved?

Note that I have considered using Partial but it does not exactly solve my issue as (Partial<A> & Partial<B>) !== ({} | A | B | (A & B)).

Bertrand Caron
  • 2,525
  • 2
  • 22
  • 49
  • Does [this approach](https://tsplay.dev/WG8yXw) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz May 05 '23 at 02:41
  • @jcalz This does seem to work (as far as matching my expected output)! Do you want to post it an answer so I can accept it (and maybe explain how it works, I am not sure to understand)? – Bertrand Caron May 05 '23 at 03:07

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360