1

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)
cefn
  • 2,895
  • 19
  • 28
  • I am investigating whether https://github.com/microsoft/TypeScript/issues/35890#issuecomment-712253565 and https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-280129798 are the final answer for the implementation I have chosen (e.g. the known issue has been considered and rejected by Typescript as a WONTDO). – cefn Jan 23 '22 at 09:57
  • However, there may still be a workaround that means I can rely on the compiler by approaching it differently if anyone has any ideas. – cefn Jan 23 '22 at 09:59
  • Does this answer your question: https://stackoverflow.com/questions/52677576/typescript-discriminated-union-allows-invalid-state/52678379#52678379 – Titian Cernicova-Dragomir Jan 23 '22 at 10:01
  • Hey thanks, Titian! It forces compiler errors, including at the line I was concerned about ( see https://tsplay.dev/wX7RLW ). The errors are only fixed by making runtime code stricter. I would struggle to share this magic with other developers in my team though. A less principled approach is to declare `type Versioned = {id?:undefined, rev?:undefined} | { id: string, rev?:undefined } | { id: string; rev: string };` Forces the runtime code to be stricter (handle the errored case that arises from loose excess properties) but doesn't tighten the excess properties (see https://tsplay.dev/wOJadW ). – cefn Jan 23 '22 at 10:57

1 Answers1

2

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) */
jsejcksn
  • 27,667
  • 4
  • 38
  • 62