feat: Support Zod values in Result transforms (#23583)

This commit is contained in:
Sergei Zharinov 2023-07-27 18:17:18 +03:00 committed by GitHub
parent a9af34cf8c
commit 674c6fca49
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 150 additions and 33 deletions

View file

@ -101,10 +101,9 @@ export class RubyGemsDatasource extends Datasource {
packageName: string packageName: string
): AsyncResult<ReleaseResult, Error | ZodError> { ): AsyncResult<ReleaseResult, Error | ZodError> {
const url = joinUrlParts(registryUrl, '/info', packageName); const url = joinUrlParts(registryUrl, '/info', packageName);
return Result.wrap(this.http.get(url)).transform(({ body }) => { return Result.wrap(this.http.get(url)).transform(({ body }) =>
const res = GemInfo.safeParse(body); GemInfo.safeParse(body)
return res.success ? Result.ok(res.data) : Result.err(res.error); );
});
} }
private getReleasesViaDeprecatedAPI( private getReleasesViaDeprecatedAPI(
@ -117,10 +116,7 @@ export class RubyGemsDatasource extends Datasource {
const bufPromise = this.http.getBuffer(url); const bufPromise = this.http.getBuffer(url);
return Result.wrap(bufPromise).transform(({ body }) => { return Result.wrap(bufPromise).transform(({ body }) => {
const data = Marshal.parse(body); const data = Marshal.parse(body);
const releases = MarshalledVersionInfo.safeParse(data); return MarshalledVersionInfo.safeParse(data);
return releases.success
? Result.ok(releases.data)
: Result.err(releases.error);
}); });
} }
} }

View file

@ -372,7 +372,7 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
): AsyncResult<ResT, SafeJsonError> { ): AsyncResult<ResT, SafeJsonError> {
const args = this.resolveArgs<ResT>(arg1, arg2, arg3); const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
return Result.wrap(this.requestJson<ResT>('get', args)).transform( return Result.wrap(this.requestJson<ResT>('get', args)).transform(
(response) => response.body (response) => Result.ok(response.body)
); );
} }

View file

@ -1,3 +1,4 @@
import { z } from 'zod';
import { logger } from '../../test/util'; import { logger } from '../../test/util';
import { AsyncResult, Result } from './result'; import { AsyncResult, Result } from './result';
@ -68,6 +69,18 @@ describe('util/result', () => {
}, 'nullable'); }, 'nullable');
expect(res).toEqual(Result.err('oops')); expect(res).toEqual(Result.err('oops'));
}); });
it('wraps zod parse result', () => {
const schema = z.string().transform((x) => x.toUpperCase());
expect(Result.wrap(schema.safeParse('foo'))).toEqual(Result.ok('FOO'));
expect(Result.wrap(schema.safeParse(42))).toMatchObject(
Result.err({
issues: [
{ code: 'invalid_type', expected: 'string', received: 'number' },
],
})
);
});
}); });
describe('Unwrapping', () => { describe('Unwrapping', () => {
@ -149,6 +162,12 @@ describe('util/result', () => {
'Result: unhandled transform error' 'Result: unhandled transform error'
); );
}); });
it('automatically converts zod values', () => {
const schema = z.string().transform((x) => x.toUpperCase());
const res = Result.ok('foo').transform((x) => schema.safeParse(x));
expect(res).toEqual(Result.ok('FOO'));
});
}); });
describe('Catch', () => { describe('Catch', () => {
@ -416,6 +435,22 @@ describe('util/result', () => {
expect(res).toEqual(Result.ok('F-O-O')); expect(res).toEqual(Result.ok('F-O-O'));
}); });
it('asynchronously transforms Result to zod values', async () => {
const schema = z.string().transform((x) => x.toUpperCase());
const res = await Result.ok('foo').transform((x) =>
Promise.resolve(schema.safeParse(x))
);
expect(res).toEqual(Result.ok('FOO'));
});
it('transforms AsyncResult to zod values', async () => {
const schema = z.string().transform((x) => x.toUpperCase());
const res = await AsyncResult.ok('foo').transform((x) =>
schema.safeParse(x)
);
expect(res).toEqual(Result.ok('FOO'));
});
}); });
describe('Catch', () => { describe('Catch', () => {

View file

@ -1,3 +1,4 @@
import { SafeParseReturnType, ZodError } from 'zod';
import { logger } from '../logger'; import { logger } from '../logger';
interface Ok<T> { interface Ok<T> {
@ -20,6 +21,45 @@ interface Err<E> {
type Res<T, E> = Ok<T> | Err<E>; type Res<T, E> = Ok<T> | Err<E>;
function isZodResult<Input, Output>(
input: unknown
): input is SafeParseReturnType<Input, NonNullable<Output>> {
if (
typeof input !== 'object' ||
input === null ||
Object.keys(input).length !== 2 ||
!('success' in input) ||
typeof input.success !== 'boolean'
) {
return false;
}
if (input.success) {
return (
'data' in input &&
typeof input.data !== 'undefined' &&
input.data !== null
);
} else {
return 'error' in input && input.error instanceof ZodError;
}
}
function fromZodResult<Input, Output>(
input: SafeParseReturnType<Input, NonNullable<Output>>
): Result<Output, ZodError<Input>> {
return input.success ? Result.ok(input.data) : Result.err(input.error);
}
/**
* All non-nullable values that also are not Promises nor Zod results.
* It's useful for restricting Zod results to not return `null` or `undefined`.
*/
type RawValue<T> = Exclude<
NonNullable<T>,
SafeParseReturnType<unknown, NonNullable<T>> | Promise<unknown>
>;
/** /**
* Class for representing a result that can fail. * Class for representing a result that can fail.
* *
@ -72,19 +112,25 @@ export class Result<T, E = Error> {
* *
* ``` * ```
*/ */
static wrap<T, E = Error>(callback: () => NonNullable<T>): Result<T, E>; static wrap<T, Input = any>(
zodResult: SafeParseReturnType<Input, NonNullable<T>>
): Result<T, ZodError<Input>>;
static wrap<T, E = Error>(callback: () => RawValue<T>): Result<T, E>;
static wrap<T, E = Error, EE = never>( static wrap<T, E = Error, EE = never>(
promise: Promise<Result<T, EE>> promise: Promise<Result<T, EE>>
): AsyncResult<T, E | EE>; ): AsyncResult<T, E | EE>;
static wrap<T, E = Error>( static wrap<T, E = Error>(promise: Promise<RawValue<T>>): AsyncResult<T, E>;
promise: Promise<NonNullable<T>> static wrap<T, E = Error, EE = never, Input = any>(
): AsyncResult<T, E>;
static wrap<T, E = Error, EE = never>(
input: input:
| (() => NonNullable<T>) | SafeParseReturnType<Input, NonNullable<T>>
| (() => RawValue<T>)
| Promise<Result<T, EE>> | Promise<Result<T, EE>>
| Promise<NonNullable<T>> | Promise<RawValue<T>>
): Result<T, E | EE> | AsyncResult<T, E | EE> { ): Result<T, ZodError<Input>> | Result<T, E | EE> | AsyncResult<T, E | EE> {
if (isZodResult<Input, T>(input)) {
return fromZodResult(input);
}
if (input instanceof Promise) { if (input instanceof Promise) {
return AsyncResult.wrap(input as never); return AsyncResult.wrap(input as never);
} }
@ -244,6 +290,8 @@ export class Result<T, E = Error> {
* Uncaught errors are logged and wrapped to `Result._uncaught()`, * Uncaught errors are logged and wrapped to `Result._uncaught()`,
* which leads to re-throwing them in `unwrap()`. * which leads to re-throwing them in `unwrap()`.
* *
* Zod `.safeParse()` results are converted automatically.
*
* ```ts * ```ts
* *
* // SYNC * // SYNC
@ -267,23 +315,35 @@ export class Result<T, E = Error> {
transform<U, EE>( transform<U, EE>(
fn: (value: NonNullable<T>) => AsyncResult<U, E | EE> fn: (value: NonNullable<T>) => AsyncResult<U, E | EE>
): AsyncResult<U, E | EE>; ): AsyncResult<U, E | EE>;
transform<U, Input = any>(
fn: (value: NonNullable<T>) => SafeParseReturnType<Input, NonNullable<U>>
): Result<U, E | ZodError<Input>>;
transform<U, Input = any>(
fn: (
value: NonNullable<T>
) => Promise<SafeParseReturnType<Input, NonNullable<U>>>
): AsyncResult<U, E | ZodError<Input>>;
transform<U, EE>( transform<U, EE>(
fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>> fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>>
): AsyncResult<U, E | EE>; ): AsyncResult<U, E | EE>;
transform<U>( transform<U>(
fn: (value: NonNullable<T>) => Promise<NonNullable<U>> fn: (value: NonNullable<T>) => Promise<RawValue<U>>
): AsyncResult<U, E>; ): AsyncResult<U, E>;
transform<U>(fn: (value: NonNullable<T>) => NonNullable<U>): Result<U, E>; transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): Result<U, E>;
transform<U, EE>( transform<U, EE, Input = any>(
fn: ( fn: (
value: NonNullable<T> value: NonNullable<T>
) => ) =>
| Result<U, E | EE> | Result<U, E | EE>
| AsyncResult<U, E | EE> | AsyncResult<U, E | EE>
| SafeParseReturnType<Input, NonNullable<U>>
| Promise<SafeParseReturnType<Input, NonNullable<U>>>
| Promise<Result<U, E | EE>> | Promise<Result<U, E | EE>>
| Promise<NonNullable<U>> | Promise<RawValue<U>>
| NonNullable<U> | RawValue<U>
): Result<U, E | EE> | AsyncResult<U, E | EE> { ):
| Result<U, E | EE | ZodError<Input>>
| AsyncResult<U, E | EE | ZodError<Input>> {
if (!this.res.ok) { if (!this.res.ok) {
return Result.err(this.res.err); return Result.err(this.res.err);
} }
@ -299,6 +359,10 @@ export class Result<T, E = Error> {
return result; return result;
} }
if (isZodResult<Input, U>(result)) {
return fromZodResult(result);
}
if (result instanceof Promise) { if (result instanceof Promise) {
return AsyncResult.wrap(result, (err) => { return AsyncResult.wrap(result, (err) => {
logger.warn({ err }, 'Result: unhandled async transform error'); logger.warn({ err }, 'Result: unhandled async transform error');
@ -383,8 +447,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
return new AsyncResult(Promise.resolve(Result.err(err))); return new AsyncResult(Promise.resolve(Result.err(err)));
} }
static wrap<T, E = Error, EE = never>( static wrap<T, E = Error, EE = never, Input = any>(
promise: Promise<Result<T, EE>> | Promise<NonNullable<T>>, promise:
| Promise<SafeParseReturnType<Input, NonNullable<T>>>
| Promise<Result<T, EE>>
| Promise<RawValue<T>>,
onErr?: (err: NonNullable<E>) => Result<T, E> onErr?: (err: NonNullable<E>) => Result<T, E>
): AsyncResult<T, E | EE> { ): AsyncResult<T, E | EE> {
return new AsyncResult( return new AsyncResult(
@ -393,6 +460,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
if (value instanceof Result) { if (value instanceof Result) {
return value; return value;
} }
if (isZodResult<Input, T>(value)) {
return fromZodResult(value);
}
return Result.ok(value); return Result.ok(value);
}) })
.catch((err) => { .catch((err) => {
@ -469,6 +541,8 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
* Uncaught errors are logged and wrapped to `Result._uncaught()`, * Uncaught errors are logged and wrapped to `Result._uncaught()`,
* which leads to re-throwing them in `unwrap()`. * which leads to re-throwing them in `unwrap()`.
* *
* Zod `.safeParse()` results are converted automatically.
*
* ```ts * ```ts
* *
* const { val, err } = await Result.wrap( * const { val, err } = await Result.wrap(
@ -485,25 +559,33 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
transform<U, EE>( transform<U, EE>(
fn: (value: NonNullable<T>) => AsyncResult<U, E | EE> fn: (value: NonNullable<T>) => AsyncResult<U, E | EE>
): AsyncResult<U, E | EE>; ): AsyncResult<U, E | EE>;
transform<U, Input = any>(
fn: (value: NonNullable<T>) => SafeParseReturnType<Input, NonNullable<U>>
): AsyncResult<U, E | ZodError<Input>>;
transform<U, Input = any>(
fn: (
value: NonNullable<T>
) => Promise<SafeParseReturnType<Input, NonNullable<U>>>
): AsyncResult<U, E | ZodError<Input>>;
transform<U, EE>( transform<U, EE>(
fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>> fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>>
): AsyncResult<U, E | EE>; ): AsyncResult<U, E | EE>;
transform<U>( transform<U>(
fn: (value: NonNullable<T>) => Promise<NonNullable<U>> fn: (value: NonNullable<T>) => Promise<RawValue<U>>
): AsyncResult<U, E>; ): AsyncResult<U, E>;
transform<U>( transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): AsyncResult<U, E>;
fn: (value: NonNullable<T>) => NonNullable<U> transform<U, EE, Input = any>(
): AsyncResult<U, E>;
transform<U, EE>(
fn: ( fn: (
value: NonNullable<T> value: NonNullable<T>
) => ) =>
| Result<U, E | EE> | Result<U, E | EE>
| AsyncResult<U, E | EE> | AsyncResult<U, E | EE>
| SafeParseReturnType<Input, NonNullable<U>>
| Promise<SafeParseReturnType<Input, NonNullable<U>>>
| Promise<Result<U, E | EE>> | Promise<Result<U, E | EE>>
| Promise<NonNullable<U>> | Promise<RawValue<U>>
| NonNullable<U> | RawValue<U>
): AsyncResult<U, E | EE> { ): AsyncResult<U, E | EE | ZodError<Input>> {
return new AsyncResult( return new AsyncResult(
this.asyncResult this.asyncResult
.then((oldResult) => { .then((oldResult) => {
@ -523,6 +605,10 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
return result; return result;
} }
if (isZodResult<Input, U>(result)) {
return fromZodResult(result);
}
if (result instanceof Promise) { if (result instanceof Promise) {
return AsyncResult.wrap(result, (err) => { return AsyncResult.wrap(result, (err) => {
logger.warn( logger.warn(