io-ts: Handle missing properties correctly

šŸ› Bug report

Current Behavior

const T = t.type({ a: t.unknown })
assert.strictEqual(T.is({}), true) // property 'a' is missing, but the type check still passes

Expected behavior

const T = t.type({ a: t.unknown })

// property 'a' is missing but must be present
assert.strictEqual(T.is({}), false) 

// property 'a' is present, and undefined is a valid value for unknown
assert.strictEqual(T.is({ a: undefined }), true) 

// property 'a' is present, and null is a valid value for unknown
assert.strictEqual(T.is({ a: null }), true) 

Similarly, in vanilla TypeScript:

type T = { a: unknown }

// won't compile
const T1: T = { } 

// good
const T2: T = { a: undefined } 

// good
const T3: T = { a: null } 

Reproducible example

See above

Suggested solution(s)

Will submit a pull request.

Additional context

Your environment

Software Version(s)
io-ts master
fp-ts 2.0.0
TypeScript 3.7.4

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 1
  • Comments: 26 (9 by maintainers)

Commits related to this issue

Most upvoted comments

@gcanti I’ll submit the pull request now so you can take a look as it. I can always update the PR with whatever changes make sense.

Regarding the idea of this being a breaking change, I think it’s more of a bug fix - but in this case people may have been inadvertently relying on the bug behavior. I don’t think it’s entirely black and white what to do from a semver perspective for cases like this…

Getting ahead of myself perhaps, but if you do decide to take the change – which I hope you do since I spent 2 solid days testing it! – I would be happy to write something up for people on how to migrate to the new behavior if you think that would help.

Ah… @gcanti the extra context helps a lot.

JSON indeed can’t directly represent undefined values and strips out those properties in stringify. I can see why that may lead you to consider treating undefined (and void which is just an alias for undefined) in a special way.

That said, you can use a replacer function in JSON.stringify to preserve undefined properties by substituting null or a value like "VALUE_UNDEFINED" for undefined:

https://stackoverflow.com/questions/26540706/preserving-undefined-that-json-stringify-otherwise-removes

Then, when parsing, you can use a reviver function in JSON.parse to substitute undefined - or any other value you like - for your placeholder.

As you can see by the stack overflow question, this is something that people are used to dealing with since it’s just how JSON works.

Anyway, type.decode is called on objects, not on strings - so it’s called after any potential JSON.parse. That means that the caller is responsible for providing the correct pre-decode object representation.

I really don’t think it should be up to type.decode to correct for potential mistakes made by the caller in their object representation.

For example, let’s say I have a data source that sends me both Users and HomelessUsers. Users must have an address property, but the address value can be undefined. HomelessUsers must not have an address property. That’s how I expect to distinguish between them.

Let’s also say I’ve already parsed the data to the correct object representations – or maybe I didn’t need to parse the data because my data source is creating and sending me objects directly in the same process.

Here’s some sample code:

import * as t from 'io-ts';
import { isLeft, fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import * as prettyFormat from 'pretty-format';

const User = t.type({
  name: t.string,
  address: t.union([t.string, t.undefined]),
});
type User = t.TypeOf<typeof User>;

const HomelessUser = t.type({
  name: t.string,
});
type HomelessUser = t.TypeOf<typeof HomelessUser>;

const userData = [{ name: 'Ann', address: undefined }, { name: 'Joe' }];

const eitherAnn = User.decode(userData[0]);
const eitherJoe = User.decode(userData[1]);

console.log(`eitherAnn is a ${isLeft(eitherAnn) ? 'left' : 'right'}`);
console.log(`eitherJoe is a ${isLeft(eitherJoe) ? 'left' : 'right'}`);
console.log(`eitherAnn is ${prettyFormat(fold(identity, identity)(eitherAnn))}`);
console.log(`eitherJoe is ${prettyFormat(fold(identity, identity)(eitherJoe))}`);

// OUTPUT:
// eitherAnn is a right
// eitherJoe is a right
// eitherAnn is Object {
//   "address": undefined,
//   "name": "Ann",
// }
// eitherJoe is Object {
//   "address": undefined,
//   "name": "Joe",
// }

Note that according to the data, Ann is a User who happens not to have a defined address, and Joe is a HomelessUser.

When I used User.decode() on these values, it succeeded for both – and in the process it also converted Joe from a HomelessUser to a User which is not what I wanted or expected to happen. He magically gained an address property and became a regular User, which could have all kinds of implications in my application – and is really subtle and un-documented behavior.

Now, maybe we fix is so that if we call User.is on Ann pre-decode it will return true, but on Joe it will return false. Are you saying that I have to call is before I call decode if I want to be safe and not accidentally convert Joe to a regular user? Even if I was expecting the data source to only send me regular users and it’s their mistake?

If I’m used to how Typescript types behaves, then I’m sure to be confused…

By the way, I mainly use decode as a smart constructor - for example to take data published in an event by one part of my application and to turn it into a domain type in another part of my application. io-ts helps to enforce the contract between different loosely-coupled parts of the application - and ensures that my domain objects can only contain valid data.

io-ts is awesome for these kinds of cases - not just i/o 😃