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

Intersection with record whose keys are a custom type #685

Open photz opened 1 year ago

photz commented 1 year ago

🐛 Bug report

Current Behavior

import * as t from 'io-ts';
import { either, left, right } from 'fp-ts/Either';
import * as Either from 'fp-ts/Either';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { identity } from 'fp-ts/function';

type StringWithUnderscore = `_${string}`;

const StringWithUnderscore = new t.Type<StringWithUnderscore, unknown, unknown>(
  'StringWithUnderscore',
  (u): u is StringWithUnderscore => t.string.is(u) && 0 < u.length && u.startsWith('_'),
  (u, c) =>
    either.chain(t.string.validate(u, c), s =>
      0 < s.length && s.startsWith('_') ? t.success(s as StringWithUnderscore) : t.failure(u, c)),
  identity,
);

describe('intersection with record whose keys are of type StringWithUnderscore', () => {
  it('typechecks but does not pass validation', () => {
    const Bar = t.intersection([
      t.type({ foo: t.string }),
      t.record(StringWithUnderscore, t.number)
    ]);

    type Bar = t.TypeOf<typeof Bar>;

    const bar: Bar = { foo: '', '_foo': 123 };

    expect(() => decodeOrThrow(Bar, bar)).toThrow();
  });
});

export function decodeOrThrow<A, O, I>(typ: t.Type<A, O, I>, i: I): A {
  return Either.getOrElse<t.Errors, A>((err) => {
    throw new Error(PathReporter.report(Either.left(err)).join('\n'));
  })(typ.decode(i));
}

Bar.decode(bar) fails with

  Invalid value "foo" supplied to : ({ foo: string } & { [K in StringWithUnderscore]: number })/1: { [K in StringWithUnderscore]: number }/foo: StringWithUnderscore

Expected behavior

Bar.decode(bar) should succeed

Reproducible example

See above

Suggested solution(s)

Additional context

I tried with a literal and that worked:

describe('intersection with record that has a literal as type', () => {
  it('type-checks and passes validation', () => {
    const Foo = t.intersection([
      t.type({ foo: t.string }),
      t.record(t.literal('bar'), t.number)
    ]);

    type Foo = t.TypeOf<typeof Foo>;

    const foo: Foo = { foo: '', bar: 123 };

    expect(Foo.decode(foo)).toEqual(right(foo));
  });
});

Your environment

Software Version(s)
io-ts 2.2.20
fp-ts 2.13.1
TypeScript 4.9.4
tgfisher4 commented 9 months ago

@photz I noticed a similar issue here, and this PR should address both our issues.