-
Notifications
You must be signed in to change notification settings - Fork 1
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
feat: Introduce PickType
#92
base: next
Are you sure you want to change the base?
Conversation
Still need to clean up unit tests. Also picking from a top level union isn't supported properly yet.
src/types/pick.test.ts
Outdated
{ a: 'drop', b: true, c: 2, d: 'tnsh', e: 2 }, | ||
{ a: 'drop', d: 'tnsh' }, | ||
], | ||
[{ a: 'tns' }, { a: 'tns' }], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is already checked as part of the validValues
.
src/types/pick.test.ts
Outdated
`errors in [Pick<NastyType, 'optionalUnion' | 'arr'>]:`, | ||
`- at <optionalUnion>: expected an object, got a number (13)`, | ||
`- at <arr>: expected a [nonempty], got: []`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hint, this fails on newlines, but those are presented poorly in the Jest output:
`errors in [Pick<NastyType, 'optionalUnion' | 'arr'>]:`, | |
`- at <optionalUnion>: expected an object, got a number (13)`, | |
`- at <arr>: expected a [nonempty], got: []`, | |
`errors in [Pick<NastyType, 'optionalUnion' | 'arr'>]:`, | |
'', | |
`- at <optionalUnion>: expected an object, got a number (13)`, | |
'', | |
`- at <arr>: expected a [nonempty], got: []`, |
src/types/pick.ts
Outdated
protected override typeValidator(input: unknown, options: ValidationOptions): Result<ResultType> { | ||
if (!unknownRecord.is(input)) { | ||
return this.createResult(input, undefined, { kind: 'invalid basic type', expected: 'object' }); | ||
} | ||
|
||
const constructResult: Record<string, unknown> = {}; | ||
const details: MessageDetails[] = []; | ||
for (const [key, innerType] of this.propsArray) { | ||
const missingKey = !hasOwnProperty(input, key); | ||
const partialKey = this.propsInfo[key]?.partial; | ||
|
||
if (missingKey) { | ||
partialKey || details.push({ kind: 'missing property', property: key, type: innerType }); | ||
continue; | ||
} | ||
|
||
const innerResult = innerType.validate(input[key], options); | ||
if (innerResult.ok) { | ||
constructResult[key] = innerResult.value; | ||
} else { | ||
details.push(...prependPathToDetails(innerResult, key)); | ||
} | ||
} | ||
return this.createResult(input, options.mode === 'construct' ? constructResult : input, details); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method feels kind of familiar, doesn't it? This is currently great to prove the concept. One of the things you had to change here compared to the implementation in InterfaceType
is that "partial" is now an aspect of a single property, instead of the entire type.
I think we should consider refactoring InterfaceType
to support that as well. All the ingredients are already in place. This could have the following benefits: (I haven't tried it though)
- No more need for a seperate
PickType
, we can now delegate entirely toInterfaceType
(sometimes combined with aUnionType
when the input type is a union) from within thepick
function withOptional
then returns anInterfaceType
instead of anIntersectionType
makes sense I guess?1- we can support config options like
strictMissingKeys
etc
If we decide to do that, we should do so in another branch. Let's discuss that offline.
Footnotes
-
Currently, objects with both mandatory and optional properties are implemented as an intersection of one object with the mandatory properties and one object with the optional properties. ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nieuwe issue voor aangemaakt #93
src/types/pick.ts
Outdated
return this.createResult(input, options.mode === 'construct' ? constructResult : input, details); | ||
} | ||
override accept<R>(visitor: Visitor<R>): R { | ||
// TODO: Should be fine, right? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, once you distribute over unions in the pick
function, then all that remains here is object-like types.
Pick distribueert nu over unions
const innerTypes = unionType.types | ||
.map((innerType, idx) => { | ||
const specificKeys = narrowedKeys[idx] ?? []; | ||
const keyCount = specificKeys.length; | ||
// Only build the pick union elements if at least one of their keys has been picked. | ||
// Also temporarily store the key count in a tuple to sort. | ||
return [keyCount, keyCount ? new PickType(innerType, checkOneOrMore(specificKeys)) : undefined] as const; | ||
}) | ||
// Sorting is necessary so the union type will internally match with a maximum subtype. This is so that types don't accidentally | ||
// get narrowed too much during parsing and stringifying. | ||
.sort(([aKeys, _1], [bKeys, _2]) => bKeys - aKeys) // Reverse sort: from most keys to least keys. | ||
.flatMap(([_, innerType]) => innerType ?? []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels kinda nasty, but I didn't want to touch the implementation of UnionType
to deal with finding the maximum pick type.
UnionType
seems to just find the first matching type when parsing and stringifying, so when that happens to be a narrower type in the union, you get unexpected results (see the Pick<UnionAC, 'a' | 'd'>
test case). The second conversion would also strip d
, which is weird. Same would happen with stringify.
By sorting this way, we guarantee that we don't strip too much of the properties accidentally as it will always find a biggest match first.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting idea. If we do something like this, it should be part of the UnionType
implementation. I agree that this is a major footgun, so I'm currently considering starting with a pick
operation on simpler interface types only.
I need to think and test your idea and possible other solutions to this footgun. We have had situations in our codebase where this happened, so I'm really happy if we can find a way to prevent "accidents" because of this.
return union( | ||
// Manually rename the type, because it's confusing if your union elements are suddenly in a different order. | ||
name ?? `Pick<${baseType.name}, ${keys.map(key => `'${key}'`).join(' | ')}>`, | ||
checkOneOrMore(innerTypes), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah so for example:
const A = object('A', { a: string });
const B = object('B', { a: string, b: boolean, d: undefined });
const C = object('C', { a: string, b: boolean, c: null });
const OriginalUnion = union([A, B, C]);
// OriginalUnion.name === 'A | B | C'
const PickedUnion = pick(OriginalUnion, ['a', 'b', 'd']);
// Because of the sorting, PickedUnion.name would be:
// Pick<B, 'a' | 'b' | 'd'> | Pick<C, 'a' | 'b'> | Pick<A, 'a'>
// Not wrong per se, just a bit odd that the elements are suddenly in a different order
// With the current implementation, the name will look like this instead:
// Pick<A | B | C, 'a' | 'b' | 'd'>
Still a bit in doubt about this, as the first name does show the distributive property much clearer.
return createType(new PickType(baseType, keys, name)); | ||
} | ||
|
||
export const pickPropertiesInfo = (propsInfo: PropertiesInfo, keys: OneOrMore<keyof Properties>) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exporting these and the helper functions below only for testing, but that feels a bit odd. Is there a better way?
@@ -178,7 +178,7 @@ function analyzePossibleDiscriminators( | |||
return found ? [...found.values()] : []; | |||
} | |||
|
|||
function propsInfoToProps(propsInfo: PropertiesInfo): Properties { | |||
export function propsInfoToProps(propsInfo: PropertiesInfo): Properties { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one I'm using in pick now, but also don't know if it's okay to export just like that.
Awesome work @untio11! You inspired me to take on #93 to make utility functions like this much easier in the future. Because of the issues with |
New pick type. Distributes over top level unions.