Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
tgfisher4 opened this issue Dec 4, 2023 · 0 comments

Comments

@tgfisher4
Copy link
Contributor

🐛 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant