gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.68k stars 331 forks source link

partially enumerable record missing enumerable keys passes `record.is`, contrary to TypeScript types #708

Open tgfisher4 opened 9 months ago

tgfisher4 commented 9 months ago

🐛 Bug report

Current Behavior

record.is does not agree with the TypeScript Record type in the case of "partially enumerable" records, i.e., records whose domain is the disjoint union of some enumerable type and some non-enumerable type.

In the following example (source code is also copy-pasted under Reproducible example) — https://stackblitz.com/edit/node-42t6hm?file=index.ts — this behavior is unexpected:

PartiallyEnumerableRecord.is(missingEnumerableKeys)
> true
E.isRight(PartiallyEnumerableRecord.decode(missingEnumerableKeys))
> true

Expected behavior

In the above example, I expect

PartiallyEnumerableRecord.is(missingEnumerableKeys)
> false
E.isRight(PartiallyEnumerableRecord.decode(missingEnumerableKeys))
> false

since missingEnumerableKeys is not assignable to PartiallyEnumerableRecord at the type-level.

Reproducible example

https://stackblitz.com/edit/node-42t6hm?file=index.ts

import * as t from 'io-ts';
import * as E from 'fp-ts/Either';

const Prefixed = t.refinement(
  // I use refinement to avoid needing to decode all my string literal keys below
  t.string,
  (s): s is `prefix:${string}` => s.startsWith('prefix:'),
  '`prefix:${string}`'
);

const PartiallyEnumerableRecord = t.record(
  t.union([t.literal('a'), Prefixed]),
  t.string
);
type PartiallyEnumerableRecord = t.TypeOf<typeof PartiallyEnumerableRecord>;

// According to TypeScript, a PartiallyEnumerableRecord _must_ include property `a`
type MissingEnumerableKeys = {};
const missingANotAssignableToPartiallyEnumerableRecord: MissingEnumerableKeys extends PartiallyEnumerableRecord
  ? true
  : false = false;

type HasEnumerableKeys = { a: 'a' };
const hasANotAssignableToPartiallyEnumerableRecord: HasEnumerableKeys extends PartiallyEnumerableRecord
  ? true
  : false = true;

const missingEnumerableKeys: MissingEnumerableKeys = {};

// However, `io-ts` does not require an object to include property `a` to pass as `PartiallyEnumerableRecord.is`
console.log(
  `${JSON.stringify(missingEnumerableKeys)} ${
    PartiallyEnumerableRecord.is(missingEnumerableKeys) ? 'is' : 'is not'
  } ${PartiallyEnumerableRecord.name}`
);

console.log(
  `${JSON.stringify(missingEnumerableKeys)} ${
    E.isRight(PartiallyEnumerableRecord.decode(missingEnumerableKeys))
      ? 'is'
      : 'is not'
  } ${PartiallyEnumerableRecord.name}`
);

Suggested solution(s)

getDomainKeys needs to be made more flexible, to support tracking both the enumerable and non-enumerable parts of a type: currently, any non-enumerable subtype of the domain causes record to choose nonEnumerableRecord. With this extra information, if disjoint enumerable and non-enumerable parts are found, record can choose to use an intersection to combine the corresponding enumerableRecord and nonEnumerableRecord. This disjointness is important, since otherwise the enumerable portion will be subsumed into the non-enumerable portion: 'a' | string === string.

707 is a draft of what I think this could look like. If this all sounds reasonable, I will finish cleaning it up and completing test coverage.

Additional context

Your environment

Which versions of io-ts are affected by this issue? Did this work in previous versions of io-ts? As far as I know this has never worked as expected.

Exact versions are also available in the linked StackBlitz environment.

Software Version(s)
io-ts 2.2.21
fp-ts 2.16.1
TypeScript 5.3.2