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.
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.
🐛 Bug report
Current Behavior
record.is
does not agree with the TypeScriptRecord
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:
Expected behavior
In the above example, I expect
since
missingEnumerableKeys
is not assignable toPartiallyEnumerableRecord
at the type-level.Reproducible example
https://stackblitz.com/edit/node-42t6hm?file=index.ts
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 thedomain
causesrecord
to choosenonEnumerableRecord
. With this extra information, if disjoint enumerable and non-enumerable parts are found,record
can choose to use anintersection
to combine the correspondingenumerableRecord
andnonEnumerableRecord
. 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.