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

Handle missing properties correctly #434

Closed
leilapearson opened this issue Mar 31, 2020 · 26 comments · May be fixed by #435
Closed

Handle missing properties correctly #434

leilapearson opened this issue Mar 31, 2020 · 26 comments · May be fixed by #435

Comments

@leilapearson
Copy link

leilapearson commented Mar 31, 2020

🐛 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
@gcanti gcanti added the bug label Mar 31, 2020
@gcanti
Copy link
Owner

gcanti commented Mar 31, 2020

undefined is a valid value for unknown

const x: { a: unknown } = { a: undefined } // ok

however this is a bug

const T = t.type({ a: t.unknown })
assert.strictEqual(T.is({}), true)

because the property 'a' is missing

@leilapearson
Copy link
Author

Yes. Sorry. I accidentally submitted the bug when I was still typing. I think it is stated correctly now.

@leilapearson
Copy link
Author

It turns out the bug isn't limited to t.unknown so the fix will cover a more general issue.

The current implementation allows for a property to be missing if the type of that property allows for undefined values, but that isn't correct. A property can only be missing if the property is optional.

In TypeScript, type { a: number | undefined } is not the same as type { a?: number }, nor is it the same as { a?: number | undefined }

Here are some examples that illustrate the difference and serve as a reference for the expected behavior:

   const a1: { a: undefined } = {  a: undefined } // ok
   const b1: { a: undefined } = {} // error
   const c1: { a: undefined } = { a: 1 } // error
   const d1: { a?: undefined } = { a: undefined } // ok
   const e1: { a?: undefined } = {} // ok
   const f1: { a?: undefined } = { a: 1 } // error

   const a2: { a: number } = { a: undefined } // error
   const b2: { a: number } = {} // error
   const c2: { a: number } = { a: 1 } // ok
   const d2: { a?: number } = { a: undefined } // ok
   const e2: { a?: number } = {} // ok
   const f2: { a?: number } = { a: 1 } // ok

   const a3: { a: number | undefined } = { a: undefined } // ok
   const b3: { a: number | undefined } = {} // error
   const c3: { a: number | undefined } = { a: 1 } // ok
   const d3: { a?: number | undefined } = { a: undefined } // ok
   const e3: { a?: number | undefined } = {} // ok
   const f3: { a?: number | undefined } = { a: 1 } // ok

Also note that void and unknown are the only types that allow you to assign undefined as a value without explicitly specifying that undefined should be allowed.

    const a: void = undefined; // ok
    const b: unknown = undefined; // ok
    const c: null = undefined; // error
    const d: string = undefined; // error
    const e: number = undefined; // error
    const f: number[] = undefined; // error
    const g: 'literal' = undefined; // error
    const h: {} = undefined; // error
    const i: { name: string } = undefined; // error
    const j: null | undefined = undefined; // ok

Anyway, I've completed the fix and added a bunch of additional tests to make sure everything behaves as expected. I also modified a few existing tests that were expecting missing properties to be allowed where they shouldn't be.

The fix is only a few lines. I'm thinking I might have added more tests than I need so I'll review the tests in the morning to see what tests I can safely eliminate before submitting a pull request.

leilapearson added a commit to relmify/io-ts that referenced this issue Apr 1, 2020
A property can only be missing if the property is optional.
Properties with types that accept undefined values must
always be present unless they are optional properties.
@leilapearson leilapearson changed the title undefined should not be accepted for properties with unknown types Handle missing properties correctly Apr 1, 2020
@gcanti
Copy link
Owner

gcanti commented Apr 1, 2020

I also modified a few existing tests that were expecting missing properties to be allowed where they shouldn't be

mmmh this is unexpected, this bug should involve the is function only

@leilapearson
Copy link
Author

leilapearson commented Apr 1, 2020

@gcanti Here's a list of test cases that fail without the fix:

FAIL  test/type.ts
  type
    `is` should return `false` for
      ✕ props: {"a": [UndefinedType]}, name: undefined, value: {} (3ms)
      ✕ props: {"a": [VoidType]}, name: undefined, value: {} (1ms)
      ✕ props: {"a": [UnknownType]}, name: undefined, value: {}
    `decode` should fail decoding with
      ✕ props: {"a": [UndefinedType]}, name: undefined, value: {} (15ms)
      ✕ props: {"a": [VoidType]}, name: undefined, value: {}
      ✕ props: {"a": [UnknownType]}, name: undefined, value: {}

I checked the tests against what the TypeScript compiler does in the equivalent cases, so I think they're real failures... Let me know if I missed something though.

**Edit ** : I removed some test cases from the above list because they weren't cases where decode succeeded but should have failed. The tests were failing with the old code and the new tests only because the decode errors were different - not because the behavior was wrong.

@gcanti
Copy link
Owner

gcanti commented Apr 1, 2020

@leilapearson decode, as opposed to is, is not required to align with the TypeScript compiler.

The following fix should be enough

// `is` definition in `type` combinator

(u): u is { [K in keyof P]: TypeOf<P[K]> } => {
  if (UnknownRecord.is(u)) {
    for (let i = 0; i < len; i++) {
      const k = keys[i]
      const uk = u[k]
      if ((uk === undefined && !hasOwnProperty.call(u, k)) || !types[i].is(uk)) {
        return false
      }
    }
    return true
  }
  return false
}

@leilapearson
Copy link
Author

leilapearson commented Apr 1, 2020

Hmm...

@gcanti Wouldn't it be better to align with the typescript compiler?

I have the whole fix ready and fully tested. It's only 4 lines of actual code in t.type. I've added lots of additional test code though :-)

The fix only impacts failure cases - meaning things that were expected to fail that didn't. This is based on the typescript compiler but also makes sense logically based on the definition of optional properties versus properties with undefined values.

io-ts does have support for optional properties via t.partial and t.intersection - so everyone should be able to fix any code they have that was succeeding before and would fail with the fix.

Of course this will be a breaking change for anyone relying on the old behavior - including anyone who happens to expect the current behavior of properties with unknown types.

I guess the fix as I've written it can only go into the 3.x release...

If you like, I can write a version of the fix that allows people to opt in to the new behavior in 2.x too... ? e.g. for the 2.x branch we could have two versions of t.type so people can select the newer one if they want the new behavior and otherwise stick with the older one, which can be deprecated?

Not sure what I'd name the newer t.type ...

Thoughts?

@gcanti
Copy link
Owner

gcanti commented Apr 1, 2020

Wouldn't it be better to align with the typescript compiler?

No, decode is a parser, not a validator. So for example you can have a decoder which parses a string into a number:

from the README

import { either } from 'fp-ts/lib/Either'

const NumberFromString = new t.Type<number, string, unknown>(
  'NumberFromString',
  t.number.is,
  (u, c) =>
    either.chain(t.string.validate(u, c), s => {
      const n = +s
      return isNaN(n) ? t.failure(u, c, 'cannot parse to a number') : t.success(n)
    }),
  String
)

console.log(NumberFromString.decode('1')) // => right(1)
console.log(NumberFromString.is('1')) // false

However is should be aligned with the typescript compiler, so this is a bug

const T = t.type({ a: t.unknown })
assert.strictEqual(T.is({}), true)

@leilapearson
Copy link
Author

@gcanti

Well the fix is in is and in validate and validate sure sounds like a validator to me? The behavior of decode only changes because decode is the same as validate with a default context.

I do see your point about parsing though. It's true that custom types can be written to parse from anything to anything else and that's good and highly useful.

That said, I still think it makes sense - and would be expected by most people - for the basic types in io-ts - the ones with the same names as the language types - to align with TypeScript. These are the types that everyone is building on, and if these types don't behave consistently with TypeScript I think it will make it harder for people to use io-ts correctly and to predict the behavior of their types.

People who create custom types are free to do as they wish - including turning strings into other things which is certainly a common case. Nothing about this fix prevents that...

It's likely I'm still missing something though, so putting my curiosity hat on...

  1. Is there some reason that it's better to for io-ts to treat properties with undefined values as optional properties in some cases?
  2. If so, in which cases? Specifically, looking at the failing test cases above, which of those would be better if the expectation was changed so that those cases are expected to pass?
  3. Why these specific cases?

Thanks for your help as always.

@leilapearson
Copy link
Author

@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.

@gcanti
Copy link
Owner

gcanti commented Apr 2, 2020

the fix is in is and in validate

I suggest to discuss them separately, I'll gladly accept a PR containing only a fix for is (thanks for the bug report).


IIRC the current behaviour of decode is based on the following observation: the roundtrip

encode -> JSON.stringify -> JSON.parse -> decode

shouldn't fail.

Example

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

console.log(T.decode(JSON.parse(JSON.stringify(T.encode({ a: undefined }))))) // right({ a: undefined })

validate sure sounds like a validator to me?

The name validate is a legacy thing (io-ts started a few years ago as a porting of tcomb), decode (which was added later on) is more appropriate.

@leilapearson
Copy link
Author

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 :-)

@gcanti
Copy link
Owner

gcanti commented Apr 3, 2020

However is should be aligned with the typescript compiler, so this is a bug

Actually, after some more investigation, this is a regression introduced in the last commit, @leilapearson I'll put up a PR for this and release version 2.1.3.

@gcanti gcanti added discussion and removed bug labels Apr 4, 2020
@leilapearson
Copy link
Author

@gcanti if you look at my PR I think you'll find that it covers what you need? It undoes the regression while still fixing #423 and it adds a lot of useful test cases. I think you can use this PR as is?

@leilapearson
Copy link
Author

@gcanti I merged your latest into my branch and updated the pull request.

In my branch, is() and decode() will always fail if a non-optional property is missing. This includes cases where the property type is t.unknown or t.undefined.

In master, is() will always fail if a non-optional property is missing and decode() will almost always fail if a non-optional property is missing. There are only two specific exceptions where it will not fail - and will instead create the missing property:

  1. If the property type being decoded to is t.unknown
  2. If the property type being decoded to is t.undefined

So... have you given this behavior any more thought?

I still think it is confusing to have these two exceptions, and that it's confusing to magically create properties that don't exist. Codecs in general can certainly do such things, but t.type() in particular should be more predictable and more aligned with TypeScript interface types I believe.

That said, I remain curious and I'd love to hear thoughts and opinions from anyone in the community.

@mmkal
Copy link
Contributor

mmkal commented Apr 6, 2020

Nit: a third exception is t.any.

My two cents: making .decode fail on missing properties that allow undefined/unknown/any would be a big breaking change so definitely shouldn't go into v2.

In my opinion, it also shouldn't go into the next version, because decoding to undefined is a useful feature, and an unnecessarily disruptive change, even for a major version.

When a property is undefined, unknown or any, decode is able to get a value conforming to the type specified, so it'd be surprising to me if it reported that it wasn't able to. Expecting people to use a custom replacer when parsing JSON would be very strange, and often not feasible - e.g. if relying on a server framework which handles json deserialisation automatically.

One note - I agree that there being a difference between .is(...) and .decode(...)._tag === 'Right' for simple types can cause confusion, since it's subtle and the difference is only in "optional" properties. Usually I've seen people go the opposite direction to this proposal, and assume that t.type({ x: t.undefined }).is({}) would return true rather than t.type({ x: t.undefined }).decode({}) would return Left.

A solution to the above problem I've suggested in the past is this PR, which introduces an optional combinator that allows typescript, .is and .decode to all agree on whether the property can be missing.

@leilapearson
Copy link
Author

leilapearson commented Apr 6, 2020

Thanks @mmkal.

Yes, I didn't mention t.any and didn't include it in the extra test cases I added because it's deprecated - but you're right - it is an exception too - and deprecated isn't gone.

I was also thinking that something like a t.optional could be handy. The current method of making a property optional using t.intersection with t.type and t.partial is not as easy to read and reason about as a t.optional that you could apply to individual properties would be.

type desiredType = {
  a: string;
  b?: string;
  c: string;
};

const withPartial = t.intersection([
  t.type({
    a: t.string,
    c: t.string,
  }),
  t.partial({
    b: t.string,
  }),
]);

const withOptional = t.type({
  a: t.string,
  b: t.optional(t.string),
  c: t.string,
});

While I like the simplicity of the syntax for t.optional above I think it has a downside in that it tends to imply that the value is optional versus the property being optional. But maybe just naming it t.optionalProperty instead of t.optional would make it more clear that it's the property and not the value that is optional.

The syntax below would be pretty intuitive too:

const WithQuestionMark = t.type({
  a: t.string,
  'b?': t.string,
  c: t.string,
});

BUT... If anyone has a property key that ends in a question mark that shouldn't be optional, that would cause problems - so I definitely wouldn't be in favour of this as a default behavior. Adding a t.optionalProperty combinator would be much safer.

Anyway, putting aside for the moment the possibility of adding a new syntax for optional properties -- since the functionality can be accomplished today (albeit a little awkwardly) -- two options for adding #435 behavior but in a non-breaking way would be:

  1. Add a flag to t.type (and t.strict) that keeps the old functionality by default ... e.g. strictOptionals = false ... or permissiveOptionals = true
  2. Add an alternate function like t.typeStrictOptionals ... or maybe just t.object https://www.typescriptlang.org/docs/handbook/basic-types.html#object

@leilapearson
Copy link
Author

@mmkal - just a bit of clarification on your point:

Expecting people to use a custom replacer when parsing JSON would be very strange, and often not feasible - e.g. if relying on a server framework which handles json deserialisation automatically.

If people can't use a custom replacer, presumably they still do have the ability to specify their desired post-decode type so that the property is optional? And therefore there is no problem in the property not being in the JSON?

If they do need the property to be added with undefined (or null - or some arbitrary default value) if not present, then presumably they could add the property themselves post-decode with a simple transformation?

Just asking. Obviously this would be less convenient in these cases than the current behavior and I'm not suggesting it's a better alternative. I'm just wanting to confirm it's a possible alternative to consider.

@leilapearson
Copy link
Author

Thinking a bit more on this, I realized that my mental model for codecs is that they either act as validated type aliases or they act as transcoders.

A codec that acts as a trancoder:

  • converts between two encodings
  • will fail on decode() if the input isn't compatible
  • can be a lossless transcoder or a lossy transcoder:
    • A lossless transcoder preserves all information. If you do an decode() and then encode() the result, you'll always get the value you started with.
    • A lossy transcoder loses information in the decoding process, the encoding process, or both. If you do a decode() and then encode() the result, you may or may not get back the value you started with, depending on the data and how it is transformed.

A codec that acts as a validated type alias:

  • doesn't change the value or how it is encoded
  • will fail on decode() if the incoming value isn't compatible
  • can be thought of as a special case of a lossless transcoder where the encoding on both sides is identical.

Since t.type() is actually a codec composed from many other codecs, sometimes it can act like a validated type alias and sometimes it can act like a transcoder - either lossy or lossless. It depends on which codecs are specified for each property. If any of the supplied codecs is a transcoder, then the t.type() codec as a whole will act as a transcoder too - changing the encoding and preserving or losing information in the process.

So far everything makes sense.

What doesn't make sense is that t.unknown, t.undefined and t.any are validated type alias style codecs. The presense of any of these in a t.type() shouldn't cause the value to be encoded differently, but it does. And it shouldn't cause the original value to be lost, but it does that too.

@gcanti
Copy link
Owner

gcanti commented Apr 12, 2020

@leilapearson a codec is basically a Prism:

  • decode is getOption (but with more error infos)
  • encode is reverseGet

here's the translated Prims laws in terms of decode / encode:

  1. pipe(codec.decode(u), E.fold(() => u, codec.encode)) = u for all u in unknown
  2. codec.decode(codec.encode(a)) = E.right(a) for all a in A

where the actual meaning of = is up to you (should be an instance of Eq).

Now undefined is kind of a special value with respect to property access

const u: any = {}
const a = { a: undefined }

console.log(u['a']) // => undefined
console.log(a['a']) // => undefined

so when we are using type we can assume that the following holds

{} = { a: undefined }

If you really care about optional properties, you can use partial instead.

@leilapearson
Copy link
Author

leilapearson commented Apr 12, 2020

console.log(`a === u is ${a === u}`);
console.log(`a == u is ${a == u}`);

// Output:
// a === u is false
// a == u is false

It's true that the value of a missing property is undefined, and the value of a property that is present but contains undefined is also undefined, but the two objects are not equal.

I don't think t.type() is obeying the laws as stated?

import { equals } from 'expect/build/jasmineUtils'; // same equals function as used by jest

// 1. pipe(codec.decode(u), E.fold(() => u, codec.encode)) = u for all u in unknown
// 2. codec.decode(codec.encode(a)) = E.right(a) for all a in A
const input = {};
const codec = t.type({ a: t.undefined });

const decoded = codec.decode(input);
const decodedValue = fold(t.identity, t.identity)(decoded) as t.TypeOf<typeof codec>;

const reEncoded = codec.encode(decodedValue);

const options = { min: true };
const print = (value: any) => prettyFormat(value, options);

console.log(`input = ${print(input)}`);
console.log(`decodedValue = ${print(decodedValue)}`);
console.log(`reEncoded = ${print(reEncoded)}`);
console.log(`equals(reEncoded, input, undefined, true) is ${equals(reEncoded, input, undefined, true)}`);

// Output:

// input = {}
// decodedValue = {"a": undefined}
// reEncoded = {"a": undefined}
// equals(reEncoded, input, undefined, true) is false

Edit:
I realized my object comparison was incorrect so I'm now using the the equals() function that jest uses. A default equals() with no options will return true, but a strict equals() as I've done above - returns false.

@leilapearson
Copy link
Author

Hi again @gcanti,

Just to be thorough I tried a similar test using prisms from monacle-ts. Everything works as expected (the prism laws hold) when I differentiate types in a sum type based on one of the types having a missing property. At least it works fine as long as I define my predicates correctly :-)

I also fixed my object comparison in the code above.

You are correct that if you compare the objects using .toJSON() or using jest equals the input and reEncoded values will seem to be equal.

However, if you compare by using the results of a prettyFormat or a strictEquals (which is what I used above) then you can see that the values are not actually equal.

Presumably the laws should hold both for a strictEquals comparison and a non-strict equals comparison?

Or am I misunderstanding or doing something wrong?

Thanks for your patience and time on this.

@gcanti
Copy link
Owner

gcanti commented Apr 15, 2020

the laws should hold both for a strictEquals comparison and a non-strict equals comparison?

@leilapearson the meaning of = is up to you (it depends on the semantic of your data type I guess).

I realized my object comparison was incorrect

"incorrect" is not the right word, you can derive a "correct" Eq instance from ===, it's just useless.

In general strict equality is useless (except for primitive types) because many functions would be unexpectedly impure

import { eqStrict } from 'fp-ts/lib/Eq'

interface Person {
  name: string
}

// is this function pure?...
function makePerson(name: string): Person {
  return { name }
}

// ...no, IF we use `eqStrict` as equality for `Person`
console.log(eqStrict.equals(makePerson('foo'), makePerson('foo'))) // => false

A more sensible equality would be: "two Persons are equal iif their names are equal".

That's the equality you get from getStructEq

import { getStructEq, eqString } from 'fp-ts/lib/Eq'

interface Person {
  name: string
}

// is this function pure?...
function makePerson(name: string): Person {
  return { name }
}

// ...yes
console.log(getStructEq({ name: eqString }).equals(makePerson('foo'), makePerson('foo'))) // => true

Back to our problem, we can just assume that the following holds

{} = { a: undefined }

for example using the following Eq instance

import { Eq, fromEquals } from 'fp-ts/lib/Eq'

const eq: Eq<{ a?: unknown } | { a: unknown }> = fromEquals((x, y) => x.a === y.a)

console.log(eq.equals({}, { a: undefined })) // => true

@leilapearson
Copy link
Author

Hi @gcanti

We're talking about a different type of strict equals. The jest version of strictEquals ( or actually .toStrictEqual) is an equivalence test - not a "this is the same object in memory" test.

Using the jest version of strictEquals with your examples, the makePerson function is still pure - but the last object comparison returns false.

import { equals } from 'expect/build/jasmineUtils'; // same equals function as used by jest

export const runEqualsExample = () => {
const strictEquals = (a: any, b: any) => equals(a, b, undefined, true);

interface Person {
  name: string;
}

function makePerson(name: string): Person {
  return { name };
}

console.log(equals(makePerson('foo'), makePerson('foo'))); // true
console.log(strictEquals(makePerson('foo'), makePerson('foo'))); // true

console.log(equals({}, { a: undefined })); // true
console.log(strictEquals({}, { a: undefined })); // false
};

The documentation for the jest version of strictEquals states the following:

.toStrictEqual(value)

Use .toStrictEqual to test that objects have the same types as well as structure.

Differences from .toEqual:

  • Keys with undefined properties are checked. e.g. {a: undefined, b: 2} does not match {b: 2} when using .toStrictEqual.
  • Array sparseness is checked. e.g. [, 1] does not match [undefined, 1] when using .toStrictEqual.
  • Object types are checked to be equal. e.g. A class instance with fields a and b will not equal a literal object with fields a and b.

The key point here is that while .toEqual only tests structural equality, .toStrictEqual tests both types and structure.

In a library where type safety is a major goal, I was expecting that the jest version of strictEquals would hold. Creating properties that don't exist can be convenient though, so having the option to do that is nice too.

@leilapearson
Copy link
Author

P.S. I also just realized that one of the main reasons that it's helpful to create properties when their values are undefined is that it's easier and more readable to do this:

export const aType = t.type({ 
  a: t.union([t.string, t.undefined]),
  b: t.string,
})

than it is to do this:

export const aType = t.intersection([t.partial({ a: t.string}), t.type({ b: t.string})]); 

The resulting types are more readable too.

If it was just as easy to specify optional properties as optional values - as @mmkal was pushing for with PR #266 - then people probably wouldn't rely on this behavior so much.

@gcanti gcanti closed this as completed Apr 16, 2020
@leilapearson
Copy link
Author

Hi @gcanti. I see you closed this, and probably I should just take the hint - but I'm honestly only trying to help improve what I think is a very valuable and important library. This is not about my use of the library personally. At this point I know how t.type() behaves better than most and can code accordingly. I just think the current behavior has some magic in it that will bite some people - even if you don't have any explicit optional properties in your types.

For example, say I have these types:

type User = { name: string; address: string | undefined };
type HomelessUser = { name: string };
type AnyUser = User | HomelessUser;

AnyUser is just a standard sum type.

Now, I know I can't discriminate between the types in this sum type by checking the value of the address field. It might be undefined in one, and it will always be undefined in the other. So in code that needs to discriminate, I check if the property is present or absent instead.

You'd be right to argue that adding a tag to my types would be a better approach, but everything works fine with this approach too.

Next I decide I should switch to io-ts types to be sure I have a valid AnyUser before I work with the data. Maybe I also want to introduce some extra validation on user names in the future and this is the first step.

I create what I think are the equivalent types using io-ts:

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

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

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

Unfortunately, once I introduce io-ts and use AnyUser.decode(), I can no longer discriminate between the two types at all. The decoded values will always have an address field. Despite what t.Type says, the decoded type will always be a { name: string; address: string | undefined }. If I was sending some information about shelters to users with no address field, they won't get that information now.

type EncodedAnyUser = ReturnType<typeof AnyUser.encode>; 
// { name: string; address: string | undefined; } | { name: string }

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

console.log('Given this user data:');
console.log(`${pretty(userData[0])}`); // {"address": undefined, "name": "Ann"}
console.log(`${pretty(userData[1])}`); // {"name": "Joe"}

console.log('Send info about shelters to any users who do not have an address property');
processUsers(userData);
// no info sent to Ann with data {"address": undefined, "name": "Ann"} -- Good
// no info sent to Joe with data {"address": undefined, "name": "Joe"} -- Oops! 

Anyway, that's the crux of it for me. By using io-ts I unexpectedly lost the ability to discriminate types in certain sum types.

I'll be quiet now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants