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

Add SetRequiredDeep type #939

Merged
merged 8 commits into from
Dec 30, 2024

Conversation

hugomartinet
Copy link
Contributor

@hugomartinet hugomartinet commented Aug 12, 2024

Closes #796

This pull request adds a new SetRequiredDeep which, like SetRequired, allows to make one or several keys in a model required. It adds the possibility to select nested keys by specifying their path.

Example

import type {SetRequiredDeep} from 'type-fest';

type Foo = {
	a?: number;
	b?: string;
	c?: {
		d?: number
	}[]
}

type SomeRequiredDeep = SetRequiredDeep<Foo, 'a' | `c.${number}.d`>;
// type SomeRequiredDeep = {
// 	a: number; // Is now required
// 	b?: string;
// 	c: {
//    d: number // Is now required
//  }[]
// }

@sindresorhus sindresorhus requested a review from Emiyaaaaa August 12, 2024 23:41
@Emiyaaaaa
Copy link
Collaborator

Emiyaaaaa commented Aug 14, 2024

Can this feature be added on SetRequired instead of a new type? just move code into SetRequired

@sindresorhus how about you think?

@sindresorhus
Copy link
Owner

The problem with doing both shallow and deep in a single method is that the deep logic affects the performance even if you don't need deep. This was the case for Simplify, where we had to split it out into a SimplifyDeep version.

source/set-required-deep.d.ts Show resolved Hide resolved
test-d/set-required-deep.ts Outdated Show resolved Hide resolved
@hugomartinet hugomartinet requested a review from Emiyaaaaa August 19, 2024 07:12
test-d/set-required-deep.ts Show resolved Hide resolved
test-d/set-required-deep.ts Show resolved Hide resolved
source/set-required-deep.d.ts Outdated Show resolved Hide resolved
@hugomartinet hugomartinet force-pushed the feat/SetRequiredDeep branch 2 times, most recently from 1c4f238 to e3c2874 Compare August 20, 2024 09:01
@hugomartinet hugomartinet requested a review from Emiyaaaaa August 20, 2024 09:03
@Emiyaaaaa
Copy link
Collaborator

Need to resolve all conversation

@hugomartinet
Copy link
Contributor Author

Need to resolve all conversation

Done

@sindresorhus sindresorhus requested review from Beraliv and removed request for Beraliv October 29, 2024 09:18
@Beraliv
Copy link

Beraliv commented Oct 29, 2024

@sindresorhus @hugomartinet I'll have a look at your PR later today or tomorrow


// Set nested key to required
declare const variation1: SetRequiredDeep<{a?: number; b?: {c?: string}}, 'b.c'>;
expectType<{a?: number; b: {c: string}}>(variation1);
Copy link

@Beraliv Beraliv Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You haven't specified 'b' | 'b.c', therefore the expected behaviour should be expectType<{a?: number; b?: {c: string}}>(variation1); (b is still optional)

Same below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this remark. I actually did this on purpose, in my mind I thought that this is what we want.

Since SetRequired<..., 'b.c'> would mean that we want b.c to always be defined, hence we also need b to always be defined. Do you disagree ?

The reason is that I thought it would be fastidious when having nested objects with many levels. Writing SetRequired<..., 'a.b.${number}.c'> is lighter than writing SetRequired<..., 'a' | 'a.b' | 'a.b.${number}' | 'a.b.${number}.c'>.

What do you think ? If you're confident on making only the nested key required I can work on that again 😀


// Set key inside array to required
declare const variation7: SetRequiredDeep<{a?: Array<{b?: number}>}, `a.${number}.b`>;
expectType<{a: Array<{b: number}>}>(variation7);
Copy link

@Beraliv Beraliv Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here (and below), the only required path `a.${number}.b` is specified, not 'a' | `a.${number}.b`, therefore the expected behaviour should be expectType<{a?: Array<{b: number}>}>(variation7);

@Beraliv
Copy link

Beraliv commented Oct 30, 2024

@hugomartinet I left my thoughts, I don't think it's ready because of a bug in the implementation (specified in my 2 comments where exactly), but I will leave it up to you (cc @sindresorhus)

@hugomartinet hugomartinet requested a review from Beraliv October 31, 2024 08:26
@hugomartinet
Copy link
Contributor Author

@hugomartinet I left my thoughts, I don't think it's ready because of a bug in the implementation (specified in my 2 comments where exactly), but I will leave it up to you (cc @sindresorhus)

@Beraliv I pushed a correction, I think I managed to take into account the expected behaviour you mentioned, thanks for pointing it out!

@hugomartinet
Copy link
Contributor Author

@sindresorhus @Beraliv How should we proceed on this?
Do you still have comments and suggestions?

@Beraliv
Copy link

Beraliv commented Dec 13, 2024

@hugomartinet thank you for your patience! If it waits, I'll leave my comments by the end of the year (next 2 weeks). If not, proceed with what you have and I'll raise bugs later if I find anything unexpected

Copy link
Collaborator

@som-sm som-sm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hugomartinet The current implementation seems to fail in the following cases:

  1.  type T1 = SetRequiredDeep<{ a: 1; b: { c?: 1 } }, 'b.c'>;
     //   ^? type T1 = { a: 1; b?: { c: 1 } }

    Shouldn't make b optional.

  2.  type T1 = SetRequiredDeep<{ a: 1; readonly b: { c?: 1 } }, 'b.c'>;
     //   ^? type T1 = { a: 1; b?: { c: 1 } }

    Doesn't preserve the readonly modifier on b.

  3.  type T1 = SetRequiredDeep<{ a: 1; readonly b: { c?: 1 } | number }, 'b.c'>;
     //   ^? type T1 = { a: 1; b?: { c: 1 } }

    Doesn't preserve the | number on b.

  4.  type T1 = SetRequiredDeep<{ 0: 1; 1: { 2?: string } }, '1.2'>;
     //   ^? type T1 = {0: 1; 1: {2?: string}}

    Doesn't work with number keys.

@Beraliv
Copy link

Beraliv commented Dec 26, 2024

@som-sm thank you for your summary of issues, this is exactly what I experienced when I was testing changes in October. @hugomartinet can you add unit tests for all scenarios I and @som-sm mentioned and fix those? Thank you

@hugomartinet
Copy link
Contributor Author

@som-sm @Beraliv

Thanks for the update, I added these test cases and fixed them 👍

@hugomartinet hugomartinet requested a review from som-sm December 27, 2024 21:40
@som-sm
Copy link
Collaborator

som-sm commented Dec 29, 2024

@hugomartinet Looks like it's not working properly with index signatures:

type Test = SetRequiredDeep<{[x: string]: unknown; a?: number}, 'a'>;
//   ^? {[x: string]: unknown; [x: number]: unknown;}

Should have returned {[x: string]: unknown; a: number;} instead.


And the implementation seems to have gotten really complicated, here's a much simpler implementation that passes all the existing tests and also works with index signatures.

export type SetRequiredDeep<Type, KeyPaths extends Paths<Type>> =
Type extends NonRecursiveType
	? Type
	: SimplifyDeep<(
		{[K in keyof Type as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: Type[K]} extends infer RequiredPart
			? keyof RequiredPart extends never ? unknown : RequiredPart
			: never) & {
		[K in keyof Type]: Extract<KeyPaths, `${K & (string | number)}.${string}`> extends never
			? Type[K]
			: SetRequiredDeep<Type[K], KeyPaths extends `${K & (string | number)}.${infer Rest extends Paths<Type[K]>}` ? Rest : never>
	}>;

type StringToNumber<Keys extends string> = Keys extends `${infer N extends number}` ? N : never;

@hugomartinet WDYT? Do you see any cases where the above implementation might fail?

@hugomartinet
Copy link
Contributor Author

@hugomartinet Looks like it's not working properly with index signatures:

type Test = SetRequiredDeep<{[x: string]: unknown; a?: number}, 'a'>;
//   ^? {[x: string]: unknown; [x: number]: unknown;}

Should have returned {[x: string]: unknown; a: number;} instead.

And the implementation seems to have gotten really complicated, here's a much simpler implementation that passes all the existing tests and also works with index signatures.

export type SetRequiredDeep<Type, KeyPaths extends Paths<Type>> =
Type extends NonRecursiveType
	? Type
	: SimplifyDeep<(
		{[K in keyof Type as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: Type[K]} extends infer RequiredPart
			? keyof RequiredPart extends never ? unknown : RequiredPart
			: never) & {
		[K in keyof Type]: Extract<KeyPaths, `${K & (string | number)}.${string}`> extends never
			? Type[K]
			: SetRequiredDeep<Type[K], KeyPaths extends `${K & (string | number)}.${infer Rest extends Paths<Type[K]>}` ? Rest : never>
	}>;

type StringToNumber<Keys extends string> = Keys extends `${infer N extends number}` ? N : never;

@hugomartinet WDYT? Do you see any cases where the above implementation might fail?

Looks good to me, thanks for the simplification

@som-sm
Copy link
Collaborator

som-sm commented Dec 29, 2024

Looks good to me, thanks for the simplification

@hugomartinet Great! I'll go ahead and update the PR then.


Although, most likely the following bit can also be simplified further to just be the mapped type:

{[K in keyof Type as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: Type[K]} extends infer RequiredPart
	? keyof RequiredPart extends never ? unknown : RequiredPart
	: never

Currently, if I replace the above bit with just the mapped type:

{[K in keyof Type as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: Type[K]}

It fails to preserve the structure of arrays for some reason. So, SetReadonlyDeep<{b: Array<{c?: 1}>}>, `b.${number}.c`> doesn't return {b: Array<{c: 1}>}, instead it unwraps the array. Ideally this shouldn't have happened because this case evaluates to: {} & {b: {} & Array<{c: 1} & {c?: 1}>} which should simplify down to {b: Array<{c: 1}>}, but for some reason it doesn't. For now, I'm replacing the empty objects with unknown to make it work.

Will have to spend more time to understand what's going wrong, but I guess it's fine for now.

@hugomartinet
Copy link
Contributor Author

Thanks @som-sm!

What are the next steps for this PR?

@Beraliv
Copy link

Beraliv commented Dec 29, 2024

@som-sm @hugomartinet I'm having a last look at the PR, please bear with me

@Beraliv
Copy link

Beraliv commented Dec 29, 2024

From test perspective, everything works as expected, great work @hugomartinet & @som-sm!

@Beraliv
Copy link

Beraliv commented Dec 29, 2024

It fails to preserve the structure of arrays for some reason. So, SetReadonlyDeep<{b: Array<{c?: 1}>}>, b.${number}.c> doesn't return {b: Array<{c: 1}>}, instead it unwraps the array. Ideally this shouldn't have happened because this case evaluates to: {} & {b: {} & Array<{c: 1} & {c?: 1}> which should simplify down to {b: Array<{c: 1}>}, but for some reason it doesn't. For now, I'm replacing the empty objects with unknown to make it work.

Very interesting investigation, @som-sm! I tried to follow the path around SimplifyDeep: when I remove SimplifyDeep and your workaround with unknown,

SetRequiredDeep<{ b: Array<{ c?: 1 }> }, `b.${number}.c`> is evaluated as:

{} & {
    b: {} & ({
        c: 1;
    } & {
        c?: 1;
    })[];
}

All the next statements evaluated correctly:

// { c: 1}[]
type B1 = SimplifyDeep<{} & Array<{ c: 1 }>>;

// {b: {c: 1}[]}
type B2 = SimplifyDeep<{
	b: ({
		c: 1;
	} & {
		c?: 1;
	})[];
}>;

// {b: {c: 1}[]}
type B3 = SimplifyDeep<
	{} & {
		b: {} & ({
			c: 1;
		} & {
			c?: 1;
		})[];
	}
>;

But when I call SimplifyDeep<A>, it again unwraps arrays. I believe it can be a known quirk of TypeScript or simply a bug. I searced issues in TypeScript repo but I cannot find anything.

Copy link

@Beraliv Beraliv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hugomartinet @som-sm amazing work, thanks both of you for addressing the issues, it LGTM now!

@som-sm
Copy link
Collaborator

som-sm commented Dec 30, 2024

@Beraliv Thanks for the detailed breakdown.


I believe it can be a known quirk of TypeScript or simply a bug.

I think I've managed to find a simpler reproduction of this issue:

import {Simplify} from "type-fest";

type T1 = Simplify<{} & {c: 1}[]>; // This works as expected
//   ^? type T1 = {c: 1}[]

type T2 = Simplify<Pick<{}, never> & {c: 1}[]>; // This unwraps the array
//   ^? type T2 = {[x: number]: {c: 1}; length:...

Pick<{}, never> evaluates down to {}, but the outcome is different from when it's a literal {}. This also means my workaround doesn't actually need an unknown, it works even with a literal {}.

@som-sm
Copy link
Collaborator

som-sm commented Dec 30, 2024

Also, simplified down the following conditional:

{[K in keyof Type as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: Type[K]} extends infer RequiredPart
	? keyof RequiredPart extends never ? unknown : RequiredPart
	: never

to this:

BaseType extends UnknownArray
	? {}
	: {[K in keyof BaseType as K extends (KeyPaths | StringToNumber<KeyPaths & string>) ? K : never]-?: BaseType[K]}

I think this is much cleaner, refer this commit.


NOTE:
None of the solutions discussed so far work when the requirement is to remove an optional modifier from a tuple, like:

type T = SetRequiredDeep<{a: [string?]}, "a.0"> // doesn't remove the `?` modifier
//  actual: {a: [string?]} // returns the same thing
//  expected: {a: [string]}

And, it would need more thought in cases like these:

type T = SetRequiredDeep<{a: [string?, string?]}, "a.1"> // can't make `1` required, w/o making `0` required

But, I guess this shouldn't be a blocker.

@som-sm
Copy link
Collaborator

som-sm commented Dec 30, 2024

What are the next steps for this PR?

@hugomartinet PR LGTM!

@sindresorhus Shall we merge?

@Beraliv
Copy link

Beraliv commented Dec 30, 2024

@som-sm great, this looks even simpler now!

@sindresorhus
Copy link
Owner

None of the solutions discussed so far work when the requirement is to remove an optional modifier from a tuple, like:

type T = SetRequiredDeep<{a: [string?]}, "a.0"> // doesn't remove the ? modifier
// actual: {a: [string?]} // returns the same thing
// expected: {a: [string]}
And, it would need more thought in cases like these:

type T = SetRequiredDeep<{a: [string?, string?]}, "a.1"> // can't make 1 required, w/o making 0 required

It would be great if you could open an issue about this.

@sindresorhus sindresorhus merged commit 3d54627 into sindresorhus:main Dec 30, 2024
12 checks passed
@sindresorhus
Copy link
Owner

Looks good. Nice work, everyone 🙏

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 this pull request may close these issues.

SetRequiredDeep - is it possible?
5 participants