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
andrev
(you are writing over the last known revision, of an existing item with thatid
) - include just
id
(it's a new item with no existing revision, but itsid
is determined by app logic) - include nothing (neither
id
orrev
- 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) */