-
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
Is converting values in the scope of this library ? #56
Comments
It would be a departure from the invariant so far that runtypes never produce new values, they just validate existing ones. That said, I can't think of any specific reason that it's a bad idea. However before you invest time in implementing it, I feel honor bound to mention that |
I completely agree, it would feel a little hacky given that this is not how it works everywhere else in the lib.
Thanks for mentioning it! I actually came here looking for an alternative to io-ts, and found I liked runtypes better (which is only my subjective opinion, io-ts also seems great). Anyway, thanks for the awesome lib! :) |
I usually rely on validation libraries to attempt some conversions (Joi for example does this), and return a possible errors and the converted value. Regards |
This would be interesting for string => number conversion and 'false' => false conversion. |
I gave const Apple = Record({
color: Union(Literal('red'), Literal('green'), Literal('yellow'), // 'red' | 'green' | 'yellow'
seeds: String.withConverter(x => Number.parseFloat(x)), // number
}) This way when I'm working with an API that gives me numbers as strings, or byte arrays as hex strings, or any other non-native JSON serialization I can just throw a Would it be possible to build this as an another library so this library doesn't have to get into the business of decoding? |
Totally agree 🤣 I'm very interested in this feature and would love to start implementing. |
@yuhr |
Got it, here's that. It's excellent effort, but deprecating |
Let's compare,
With #191, the equivalent code in const TrimmedString = String.withTransform(value => value.trim(), { name: 'TrimmedString' });
TrimmedString.check(' foo bar ');
// => 'foo bar'
const AlreadyTrimmedString = String.withConstraint(
value =>
value.trim() === value || `Expected the string to be trimmed, but this one has whitespace`,
); As illustrated above, I think the feature like |
You don't have to use a custom parser. In your example, it looks like you don't want a custom parser, you just want a constraint. const TrimmedString = String.withConstraint(x => x.trim() === value)
TrimmedString.parse(' foo bar ') // throws error
TrimmedString.safeParse(' foo bar ') // returns error (IIRC, I never use this one so I forget how it surfaces errors exactly) |
I couldn't get the point of your comment. Apparently your code has no "transform", so it does not work the same way as mine. What I tried to illustrate by my example above is nothing else but the equivalent code corresponding to By the way, the equivalent code to yours in const TrimmedString = String.withConstraint(x => x.trim() === value)
TrimmedString.check(' foo bar ') // throws error
TrimmedString.validate(' foo bar ') // returns error Or, if I reuse my example code, I can write it also: AlreadyTrimmedString.check(' foo bar ') // throws error
AlreadyTrimmedString.validate(' foo bar ') // returns error All these are the concern of "conformity after trimming", not the concern of "transformation of values". |
We should probably move this conversation over to |
I had a need for this, since I wanted to use the runtypes for data coming in from our request body, query, etc. Since runtypes doesn't support type conversions out of the box, and I'm not a fan of uprooting all of the runtypes and switch to funtypes, yup, or introduce io-ts, I created support for type conversion using the reflection information of runtypes. The only requirement is for a runtype to have a brand, so that it's type converter could be resolved properly, but that could be removed from this code. Implementation: /**
* Explore the Runtypes reflection to find a type brand.
* @param reflect type info to search for brand string in.
* @returns brand string if found, otherwise, null.
*/
export function findTypeBrands(reflect: rt.Reflect): string[] | undefined {
switch (reflect.tag) {
case 'brand':
return [reflect.brand];
case 'optional':
return findTypeBrands(reflect.underlying);
case 'union': {
const brands = reflect.alternatives
.map((alt) => findTypeBrands(alt.reflect))
.flat()
.filter((val) => !!val) as string[];
return brands.length > 0 ? brands : undefined;
}
case 'intersect': {
const brands = reflect.intersectees
.map((entry) => findTypeBrands(entry.reflect))
.flat()
.filter((val) => !!val) as string[];
return brands.length > 0 ? brands : undefined;
}
case 'constraint':
return findTypeBrands(reflect.underlying);
default:
return undefined;
}
}
/**
* Gets the array's element type from reflection, or undefined if not an array.
* @param reflect array type to confirm that the type is an array.
* @returns Array's element type or undefined if not an array.
*/
export function getArrayElementType(reflect: rt.Reflect): rt.Reflect | undefined {
switch (reflect.tag) {
case 'brand':
return getArrayElementType(reflect.entity);
case 'optional':
return getArrayElementType(reflect.underlying);
case 'union':
// Only dig deeper if this is a nullable union.
if (reflect.alternatives.length === 2 && reflect.alternatives[1] === rt.Null) {
return getArrayElementType(reflect.alternatives[0].reflect);
}
return undefined;
case 'constraint':
return getArrayElementType(reflect.underlying);
case 'array':
return reflect.element;
}
return undefined;
}
/**
* Gets the tuple's component types from reflection, or undefined if not a tuple.
* @param reflect tuple type to confirm that the type is a tuple.
* @returns Tuple's component types or undefined if not a tuple.
*/
export function getTupleComponentTypes(reflect: rt.Reflect): rt.Reflect[] | undefined {
switch (reflect.tag) {
case 'brand':
return getTupleComponentTypes(reflect.entity);
case 'optional':
return getTupleComponentTypes(reflect.underlying);
case 'union':
// Only dig deeper if this in a nullable union.
if (reflect.alternatives.length === 2 && reflect.alternatives[1] === rt.Null) {
return getTupleComponentTypes(reflect.alternatives[0].reflect);
}
return undefined;
case 'constraint':
return getTupleComponentTypes(reflect.underlying);
case 'tuple':
return reflect.components;
}
return undefined;
}
/**
* Gets the record's type from reflection, or undefined if not a record.
* @param reflect record type to confirm that the type is a record.
* @returns record type or undefined if not a record.
*/
export function getRecordType(reflect: rt.Reflect): (rt.Reflect & { tag: 'record' }) | undefined {
switch (reflect.tag) {
case 'brand':
return getRecordType(reflect.entity);
case 'optional':
return getRecordType(reflect.underlying);
case 'union':
// Only dig deeper if this in a nullable union.
if (reflect.alternatives.length === 2 && reflect.alternatives[1] === rt.Null) {
return getRecordType(reflect.alternatives[0].reflect);
}
return undefined;
case 'constraint':
return getRecordType(reflect.underlying);
case 'record':
return reflect;
}
return undefined;
}
/**
* Gets the insersectee types from reflection, or undefined if not an intersection.
* @param reflect intersection type to confirm that the type is an intersection.
* @returns intersectee types or undefined if not an intersection.
*/
export function getIntersecteeTypes(reflect: rt.Reflect): rt.Reflect[] | undefined {
switch (reflect.tag) {
case 'brand':
return getIntersecteeTypes(reflect.entity);
case 'optional':
return getIntersecteeTypes(reflect.underlying);
case 'union':
// Only dig deeper if this in a nullable union.
if (reflect.alternatives.length === 2 && reflect.alternatives[1] === rt.Null) {
return getIntersecteeTypes(reflect.alternatives[0].reflect);
}
return undefined;
case 'constraint':
return getIntersecteeTypes(reflect.underlying);
case 'intersect':
return reflect.intersectees;
}
return undefined;
}
/**
* Runtype type converter storage.
*/
const typeConverters: Record<
string,
{
type: RuntypeBase<unknown>;
convert: (data: unknown) => unknown;
}
> = {};
/**
* Register a runtype with a conversion function. (Must have a brand associated)
* @param type Type to register for conversion.
* @param converter Conversion function from untyped to typed.
*/
export function registerTypeConverter(type: RuntypeBase<unknown>, converter: (data: unknown) => unknown) {
const brands = findTypeBrands(type.reflect);
if (!brands) {
throw new Error('Type must have a brand to be registered as a type converter');
}
const foundBrand = Object.keys(typeConverters).find((brand) => brands.includes(brand));
if (foundBrand) {
throw new Error(`A Type converter was already registered for this type: ${foundBrand}`);
}
for (const brand of brands) {
typeConverters[brand] = {
type,
convert: converter,
};
}
}
/**
* Parses a runtype, allowing for type conversions.
* @param type RunType to parse.
* @param data Data to parse to RunType
* @param options Conversion options: {
* stripUnknown: "Removes fields that aren't on the type.",
* check: "Apply runtype check? Turn to false if you need a higher check, e.g. parsing deep records."
* }
* @returns Parsed RunType
*/
export function parseType<T extends RuntypeBase<unknown>>(
type: T,
data: unknown,
{ stripUnknown = true, check = true }: { stripUnknown?: boolean; check?: boolean } = {},
): rt.Static<RuntypeBase<T extends RuntypeBase<infer R> ? R : never>> {
let value = data;
// Ignore type warnings, used for chaining. Return value if type information couldn't be found.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!type) return value as any;
const brands = findTypeBrands(type.reflect);
if (brands) {
for (const brand of brands) {
const typeConvertInfo = typeConverters[brand];
if (typeConvertInfo) {
value = typeConvertInfo.convert(value);
}
}
} else if (Array.isArray(value)) {
const arrayElementType = getArrayElementType(type.reflect);
const tupleTypes = getTupleComponentTypes(type.reflect);
if (arrayElementType) {
value = value.map((element) => parseType(arrayElementType, element, { check: false, stripUnknown }));
} else if (tupleTypes) {
value = value.map((element, index) => parseType(tupleTypes[index], element, { check: false, stripUnknown }));
}
} else if (value !== null && value !== undefined && typeof value === 'object') {
const recordType = getRecordType(type.reflect);
const intersecteeTypes = getIntersecteeTypes(type.reflect);
// Handle intersections of types.
if (intersecteeTypes) {
const intersectResults: Record<string, unknown>[] = [];
for (const intersecteeType of intersecteeTypes) {
intersectResults.push(
parseType(intersecteeType, value, { check: false, stripUnknown }) as Record<string, unknown>,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value = (_.merge as any)(...intersectResults);
} else if (recordType) {
const data = value as Record<string, unknown>;
const dataKeys = Object.keys(data);
const record: Record<string, unknown> = stripUnknown ? {} : data;
for (const [field, fieldType] of Object.entries(recordType.fields)) {
if (stripUnknown && !dataKeys.includes(field)) continue;
record[field] = parseType(fieldType as RuntypeBase<unknown>, data[field], { check: false, stripUnknown });
}
value = record;
}
}
// We essentially have done everything, if check is false,
// then we essentially have "casted" the value.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (check ? type.check(value) : value) as any;
} Usage: export const DateString = rt.String.withBrand('DateString').withConstraint((str) => {
const dateObj = DateTime.fromISO(str);
return dateObj.isValid || dateObj.invalidExplanation || `The date was not a valid ISO string: ${str}`;
});
export type DateString = rt.Static<typeof DateString>;
export const DateType = rt.InstanceOf(Date).withBrand('Date');
export type DateType = rt.Static<typeof DateType>;
registerTypeConverter(DateType, (value) => {
if (typeof value === 'string') {
value = new Date(DateString.check(value));
}
return value;
});
const nullableDateType = DateType.nullable().optional();
describe('parseType()', () => {
const testType = rt.Record({
date: nullableDateType,
});
it('converts values in a record type', () => {
const source = {
date: '2022-01-18T23:03:16.328Z',
};
expect(parseType(testType, source)).toEqual({
date: new Date(source.date),
});
});
}); |
I understand that having only a single responsibility is good, but knowing where these type checks are used, transformation is a very often second step. Maybe this could be done by a separate types Codec/Decoder/Encoder that can add encoding/decoding by wrapping provided Runtype. |
A common use case when validating data from external APIs is to have dates encoded as strings.
Let's say I get the following data from a JSON API:
I would want to represent it with the following Runtype:
Is there a simple way to go from the first to the second without having to use an intermediate
Record({ date: String })
runtype, and doing the validation ?I think that having a
convertFrom
method on theRuntype
interface would help a lot.The returned
Runtype
would be exactly the same, but thecheck
method would be changed to include the additional validation incheckBefore
before the one from the initialRuntype
.I could then do:
Do you think it could be added to the library (I would be willing to do it)?
The text was updated successfully, but these errors were encountered: