-
Notifications
You must be signed in to change notification settings - Fork 90
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: add Transform
#191
feat: add Transform
#191
Conversation
BREAKING CHANGE: `check` method of compound runtypes such as `Record` and `Tuple` now returns a new object instead of original one
This is correct for us too.
I think this is still remaining to be considered in some point of view, that is, we could still include unknown properties in the resulting object, but yes, it would be better to remove in favor of these issues (and 52e8900 already does this). This way, we shall state that
I don't believe this would improve performance or memory consumption in any terms, because it requires us to introduce additional strict-equality check at every single runtype validation. |
Note that |
The side effect that it removes unknown properties from validated records will also address the problem described in #111:
Obviously this will already be resolved by this PR. As I have written here, once we get |
Well, I understand in some cases it's not "easily created", especially for complex |
Oh this will break the semantics of |
Looking forward to this feature, will be super useful! |
It is a shame the non-mutating behaviour will be lost though, since it's so low overhead. |
@peterjwest Sorry, my wording was improper. I said misleading terms such as "strip", "remove" and "side effect", but by these words I did mean just "make it not present" in the returned object (and its static type) from ".check()". It doesn't touch the argument, and will always return a new object instead of the original one. I don't want to introduce any form of mutating behaviour. But that's why my current implementation makes I think we would add a new type parameter like |
I think I understand what you're saying, and that sounds great! But in regard to your question, I have absolutely no idea. |
Any news about this? This feature would be very important to our team if it gets ready (especially if extra object keys will be dropped). So far we've used this kind of utility function but it feels a bit hacky and we'd rather implement this as real real feature to the core library: const withoutExtraKeys = <T>(type: rt.Runtype<any>, value: any): T => {
const reflect = type.reflect
switch (reflect.tag) {
case 'record':
return Object.entries(reflect.fields).reduce((result, [propertyKey, propertyType]) => {
if (propertyType.tag !== 'optional' || propertyKey in value) {
result[propertyKey] = withoutExtraKeys(propertyType, value[propertyKey])
}
return result
}, {} as T)
case 'dictionary':
const valueType = reflect.value
return Object.entries(value).reduce((res, [key, value]) => {
res[key] = withoutExtraKeys(valueType, value)
return res
}, {} as T)
case 'array':
const elementType = reflect.element
return value.map((x) => withoutExtraKeys(elementType, x))
case 'tuple':
return reflect.components.map((componentType, idx) =>
withoutExtraKeys(componentType, value[idx])
) as any
case 'optional':
return value === undefined ? value : withoutExtraKeys(reflect.underlying, value)
case 'union':
for (const altType of reflect.alternatives) {
if (altType.validate(value).success) {
return withoutExtraKeys(altType, value)
}
}
return value
case 'intersect':
if (reflect.intersectees.length === 0) {
return value
} else {
return reflect.intersectees.reduce((result, intersecteeType) => {
const intersecteeValue = withoutExtraKeys(intersecteeType, value)
if (_.isPlainObject(result) && _.isPlainObject(intersecteeValue)) {
Object.assign(result, intersecteeValue)
return result
} else {
return intersecteeValue
}
}, {} as any)
}
default:
return value
}
}
export const checkNoExtraKeys = <T>(type: rt.Runtype<T>, value: unknown): T => {
type.check(value)
return withoutExtraKeys<T>(type, value)
} I can also help with this PR if it helps to get it closer to being merged! |
Here's my version of the same thing. import { mapValues, pickBy } from 'lodash';
import * as runtypes from 'runtypes';
export default function restrict<Data, RunType extends runtypes.Runtype<Data>>(RunType: RunType, data: Data) {
return restrictInternal(RunType, data) || data;
}
function restrictInternal<Data, RunType extends runtypes.Runtype<Data>>(
RunType: RunType, data: Data,
): Data | undefined {
const reflected = RunType.reflect;
if (reflected.tag === 'record') {
let mutated = false;
const newData: Data = mapValues(pickBy(data as any, (item, key) => {
const hasKey = key in reflected.fields;
if (!hasKey) mutated = true;
return hasKey;
}), (item, key) => {
const newItem = restrictInternal(reflected.fields[key], item);
if (newItem !== undefined) mutated = true;
return newItem || item;
});
if (mutated) return newData;
}
if (reflected.tag === 'array') {
let mutated = false;
const newData: Data = (data as any).map((item: any) => {
const newItem = restrictInternal(reflected.element, item);
if (newItem !== undefined) mutated = true;
return newItem || item;
});
if (mutated) return newData;
}
if (reflected.tag === 'dictionary') {
let mutated = false;
const newData: Data = mapValues(data as any, (item) => {
const newItem = restrictInternal(reflected.value, item);
if (newItem !== undefined) mutated = true;
return newItem || item;
});
if (mutated) return newData;
}
if (reflected.tag === 'union') {
const alternative = reflected.alternatives.find((alternative) => {
try {
alternative.check(data);
return true;
} catch {
return false;
}
});
return restrictInternal(alternative as any, data);
}
if (reflected.tag === 'optional') {
return restrictInternal(reflected.underlying as any, data);
}
return undefined;
} |
If we are transforming in the direction of Json => TS is there then also appetite for transformations in the opposite direction i.e TS => Json? Presumably we'd need a new function like |
@yuhr design-wise, shouldn't withTransform be inverted? Or am I thinking more on the lines of a withConverter function. Ideally my thought is that I have: export const DateType = rt.InstanceOf(Date).withConverter((val: unknown) => {
if (typeof value === 'string') {
value = new Date(DateString.check(value));
}
if (DateTime.isDateTime(value)) {
value = value.toJSDate();
}
return value;
});
export type DateType = rt.Static<typeof DateType>; That way it can deal with any kind of unknown value, and try and turn it into the Date Type, and if it fails, throw a validation error. E.g. handle parsing of Luxon objects too. To represent this with your withTransform function, do I use a union of possible input types? E.g. export const DateType = rt.Union(rt.InstanceOf(Date), rt.InstanceOf(DateTime), rt.String)
.withTransform((val: Date | DateTime | string) => {
if (typeof value === 'string') {
value = new Date(DateString.check(value));
}
if (DateTime.isDateTime(value)) {
value = value.toJSDate();
}
return value;
});
export type DateType = rt.Static<typeof DateType>; I have implemented simplisitc parsing functions for types and records in this comment: #56 (comment) With covers the parsing and stripping unknown field use cases, but having it built into the library would be a lot nicer. |
Closing as too stale; I'll file another PR for this. |
BREAKING CHANGE:
check
method of compound runtypes such asRecord
andTuple
now returns a new object instead of original oneResolves #56. It's inspired by #56 (comment). Detailed usage is in the tests. Above change is needed in order to make
Transform
work with those non-primitive runtypes. Now investigating possible side effects, bugs & design flaws...Example usage: