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

Jsonify removes all optional properties #309

Closed
bbrk24 opened this issue Nov 4, 2021 · 10 comments · Fixed by #310
Closed

Jsonify removes all optional properties #309

bbrk24 opened this issue Nov 4, 2021 · 10 comments · Fixed by #310

Comments

@bbrk24
Copy link
Contributor

bbrk24 commented Nov 4, 2021

I assume this isn't the intended behavior.

type T = Jsonify<{ x?: string }>;
// type T = { x?: undefined }

So far I'm working around it by using Partial<Jsonify<Required<T>>> rather than Jsonify<T>, but I can imagine situations where that's still not desirable (i.e. only some keys are optional, so Partial doesn't work).

I haven't looked too far into this, but if I had to guess what's happening:

  1. TypeScript automatically adds undefined to x's type, giving string | undefined.
  2. Since undefined isn't valid JSON, it fails the NotJsonable check, so Jsonify returns { x?: never }
  3. TypeScript again adds undefined, but undefined | never collapses to just undefined.

It's also worth noting that while undefined is not normally valid in JSON, undefined in an array becomes null.

@sindresorhus
Copy link
Owner

// @darcyparker

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 4, 2021

it fails the NotJsonable check, so Jsonify returns { x?: never }

This does seem to be the case. Jsonify<{ x: string | undefined }> returns { x: never }, as does Jsonify<{ x: string | (() => {}) }>. Both of these cases should probably return { x?: string }.

@darcyparker
Copy link
Contributor

@bbrk24 - Thanks for reporting these edge cases. I am looking at it now.

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 5, 2021

The underlying issue seems to be the use of Extract:

type T = Extract<string, NotJsonable>; // type T = never
// never | x == x, so...
type U = Extract<string | undefined, NotJsonable>; // type U = undefined

Exclude has the same problem in the opposite direction:

type V = Exclude<undefined, NotJsonable>; // type V = never
type W = Exclude<string | undefined, NotJsonable>; // type W = string

So I think the correct solution has to test both.

@darcyparker
Copy link
Contributor

darcyparker commented Nov 5, 2021

I understand/agree with the expected types of your example Extract cases.

You're right that the Extract<string, NotJsonable> is preventing undefined from being handled. And that's a good thing because its not JSON. An undefined value has to be mapped to never.

My understanding of the problem is that optional properties must be handled. This is different than a required property that is set to undefined.

{a: undefined | string;} looks very similar to {a?: string;}, but they are different. In the first case, the object has the property a even if the property value is set to undefined, where as the second case does not.

Am I understanding things correctly? Or I am missing something about the issue or your last comment?

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 5, 2021

Ah, you have a point there. I hadn't considered the case of an explicit type union with undefined. I'm not sure if/how it's possible to detect which one the original type is in a way agnostic to the names of the keys.

If it were possible to conditionally add the ? modifier, I might think to embed a conditional expression inside the mapped type (currently {[P in keyof T]: Jsonify<T[P]>}), but I doubt that that's possible either.

@darcyparker
Copy link
Contributor

Yeah... its not an easy problem. In my draft of #310, I made some progress... My comment that defines JsonifyObject has an initial approach I took.

But then I discovered a problem with:

declare const objectWithUndefinedValue: {x: undefined};
expectNotAssignable<JsonValue>(objectWithUndefinedValue); //I expected this to pass

The above test illustrates a problem with JsonObject... (Not Jsonify) But it impacts Jsonify. I want to solve that problem first before continuing with the rest of #310.

But you're right that it may not be possible.

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 5, 2021

I think I have an idea. I'm not 100% sure if this exact implementation will work but my idea is something like

// replace `undefined` in `T` with the corresponding member from `U`
type OverwriteUndefineds<T, U extends {[_ in keyof T]?: any}> = {
	[K in keyof T]: T[K] extends undefined ? U[K] : T[K]
};

type Jsonify<T> = // ...
// same as currently up until the object handler, which becomes:
OverwriteUndefineds<
	{[P in keyof T]: Jsonify<T[P]> extends never ? undefined : Jsonify<T[P]>},
	{[P in keyof Required<T>]?: Jsonify<Required<T>[P]>}
>
// ...

I will have to think about it a bit more to get the exact details working, but my cursory testing shows that Required<T> removes any non-explicit undefined.

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 5, 2021

And I just realized I've been overthinking this entire thing. The solution is way simpler than that: just {[P in keyof T]: Jsonify<Required<T>[P]>}.

@bbrk24
Copy link
Contributor Author

bbrk24 commented Nov 6, 2021

declare const objectWithUndefinedValue: {x: undefined};
expectNotAssignable<JsonValue>(objectWithUndefinedValue); //I expected this to pass

The above test illustrates a problem with JsonObject.

@darcyparker I'm pretty sure this is a TypeScript thing. I would imagine that if you use TypeScript 4.4 and set exactOptionalPropertyTypes to true in the tsconfig, the test will suddenly work as expected. Then again, if we could rely on exactOptionalPropertyTypes, I don’t think this issue would be reproducible anyways.

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

Successfully merging a pull request may close this issue.

3 participants