Excess properties not inferred correctly in unions : any workarounds?

I am having trouble with Typescript not enforcing excess property checks in a way that respects co-constraints within a union.

Seems that excess property checks are defeated if any branch of the union allows the field, even if the actual combination of properties is illegal for any specific branch in the union.

Are there workarounds for my case, or is it something that is likely to be fixed eventually in Typescript?

This is not merely theoretical - this poor union branch expansion for excess property checks in typescript allows runtime errors not picked up by the compiler as shown at https://tsplay.dev/m35Aqw - press Run to see the error.

USE CASE

I want a type that adds properties to serve an optimistic concurrency data model - an optional id, or an optional id AND rev like couchdb. Versioning looks like this...

type Versioned = { id: string } | { id: string; rev: string };

I would use it enable an item to support (optionally added) optimistic concurrency fields like...

type Saveable<T> = T | (T & Versioned)
type Item = Saveable<{message:string}>

PROBLEM

Unfortunately, the approach I have taken allows items that have rev but no id to be valid for the compiler.

These declarations should all be accepted by the compiler...

const itemA: Item = { message: "hi" };
const itemB: Item = { message: "hi", id: "something" };
const itemC: Item = { message: "hi", id: "something", rev: "whatever" };

This should error, since rev is an excess property of any branch without id...

const itemD: Item = { message: "hi", rev: "whatever" };

A declaration like this errors correctly, owing to excess property checks...

const itemE: Item = { message: "hi", extra: "whatever" }; 

QUESTIONS

Is there a way to define Versioned or Saveable to ensure the presence of rev without id is an excess property check error as it should be?

If not, is it likely that runtime errors like this can be eliminated in future versions of Typescript as the inference engine improves.

BACKGROUND - OPTIMISTIC CONCURRENCY MODEL

When you save an item on this optimistic concurrency model you could...

  • include both id and rev (you are writing over the last known revision, of an existing item with that id)
  • include just id (it's a new item with no existing revision, but its id is determined by app logic)
  • include nothing (neither id or rev - it's a new item with no existing revision, id can be assigned uniquely by the store)

The only way to enforce that the compiler disallows certain properties is to set them as optional and as never, like this:

TS Playground

type Versioned = (
  | { id: string; rev: string; }
  | { id: string; rev?: never; }
  | { id?: never; rev?: never; }
);

type Saveable<T> = T & Versioned;
type Item = Saveable<{message:string}>


// Valid

const itemA: Item = { message: "hi" };
const itemB: Item = { message: "hi", id: "something" };
const itemC: Item = { message: "hi", id: "something", rev: "whatever" };


// Invalid

const itemD: Item = { message: "hi", rev: "whatever" }; /*
      ^^^^^
Property 'id' is missing in type '{ message: string; rev: string; }'
but required in type '{ id: string; rev: string; }'.(2322) */

const itemE: Item = { message: "hi", extra: "whatever" }; /*
                                     ^^^^^^^^^^^^^^^^^
Object literal may only specify known properties,
and 'extra' does not exist in type 'Item'.(2322) */