Skip to content

Commit

Permalink
feat: Support Zod values in Result transforms (#23583)
Browse files Browse the repository at this point in the history
  • Loading branch information
zharinov committed Jul 27, 2023
1 parent a9af34c commit 674c6fc
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 33 deletions.
12 changes: 4 additions & 8 deletions lib/modules/datasource/rubygems/index.ts
Expand Up @@ -101,10 +101,9 @@ export class RubyGemsDatasource extends Datasource {
packageName: string
): AsyncResult<ReleaseResult, Error | ZodError> {
const url = joinUrlParts(registryUrl, '/info', packageName);
return Result.wrap(this.http.get(url)).transform(({ body }) => {
const res = GemInfo.safeParse(body);
return res.success ? Result.ok(res.data) : Result.err(res.error);
});
return Result.wrap(this.http.get(url)).transform(({ body }) =>
GemInfo.safeParse(body)
);
}

private getReleasesViaDeprecatedAPI(
Expand All @@ -117,10 +116,7 @@ export class RubyGemsDatasource extends Datasource {
const bufPromise = this.http.getBuffer(url);
return Result.wrap(bufPromise).transform(({ body }) => {
const data = Marshal.parse(body);
const releases = MarshalledVersionInfo.safeParse(data);
return releases.success
? Result.ok(releases.data)
: Result.err(releases.error);
return MarshalledVersionInfo.safeParse(data);
});
}
}
2 changes: 1 addition & 1 deletion lib/util/http/index.ts
Expand Up @@ -372,7 +372,7 @@ export class Http<Opts extends HttpOptions = HttpOptions> {
): AsyncResult<ResT, SafeJsonError> {
const args = this.resolveArgs<ResT>(arg1, arg2, arg3);
return Result.wrap(this.requestJson<ResT>('get', args)).transform(
(response) => response.body
(response) => Result.ok(response.body)
);
}

Expand Down
35 changes: 35 additions & 0 deletions lib/util/result.spec.ts
@@ -1,3 +1,4 @@
import { z } from 'zod';
import { logger } from '../../test/util';
import { AsyncResult, Result } from './result';

Expand Down Expand Up @@ -68,6 +69,18 @@ describe('util/result', () => {
}, 'nullable');
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', () => {
Expand Down Expand Up @@ -149,6 +162,12 @@ describe('util/result', () => {
'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', () => {
Expand Down Expand Up @@ -416,6 +435,22 @@ describe('util/result', () => {

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', () => {
Expand Down
134 changes: 110 additions & 24 deletions lib/util/result.ts
@@ -1,3 +1,4 @@
import { SafeParseReturnType, ZodError } from 'zod';
import { logger } from '../logger';

interface Ok<T> {
Expand All @@ -20,6 +21,45 @@ interface 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.
*
Expand Down Expand Up @@ -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>(
promise: Promise<Result<T, EE>>
): AsyncResult<T, E | EE>;
static wrap<T, E = Error>(
promise: Promise<NonNullable<T>>
): AsyncResult<T, E>;
static wrap<T, E = Error, EE = never>(
static wrap<T, E = Error>(promise: Promise<RawValue<T>>): AsyncResult<T, E>;
static wrap<T, E = Error, EE = never, Input = any>(
input:
| (() => NonNullable<T>)
| SafeParseReturnType<Input, NonNullable<T>>
| (() => RawValue<T>)
| Promise<Result<T, EE>>
| Promise<NonNullable<T>>
): Result<T, E | EE> | AsyncResult<T, E | EE> {
| Promise<RawValue<T>>
): Result<T, ZodError<Input>> | Result<T, E | EE> | AsyncResult<T, E | EE> {
if (isZodResult<Input, T>(input)) {
return fromZodResult(input);
}

if (input instanceof Promise) {
return AsyncResult.wrap(input as never);
}
Expand Down Expand Up @@ -244,6 +290,8 @@ export class Result<T, E = Error> {
* Uncaught errors are logged and wrapped to `Result._uncaught()`,
* which leads to re-throwing them in `unwrap()`.
*
* Zod `.safeParse()` results are converted automatically.
*
* ```ts
*
* // SYNC
Expand All @@ -267,23 +315,35 @@ export class Result<T, E = Error> {
transform<U, EE>(
fn: (value: NonNullable<T>) => 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>(
fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>>
): AsyncResult<U, E | EE>;
transform<U>(
fn: (value: NonNullable<T>) => Promise<NonNullable<U>>
fn: (value: NonNullable<T>) => Promise<RawValue<U>>
): AsyncResult<U, E>;
transform<U>(fn: (value: NonNullable<T>) => NonNullable<U>): Result<U, E>;
transform<U, EE>(
transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): Result<U, E>;
transform<U, EE, Input = any>(
fn: (
value: NonNullable<T>
) =>
| Result<U, E | EE>
| AsyncResult<U, E | EE>
| SafeParseReturnType<Input, NonNullable<U>>
| Promise<SafeParseReturnType<Input, NonNullable<U>>>
| Promise<Result<U, E | EE>>
| Promise<NonNullable<U>>
| NonNullable<U>
): Result<U, E | EE> | AsyncResult<U, E | EE> {
| Promise<RawValue<U>>
| RawValue<U>
):
| Result<U, E | EE | ZodError<Input>>
| AsyncResult<U, E | EE | ZodError<Input>> {
if (!this.res.ok) {
return Result.err(this.res.err);
}
Expand All @@ -299,6 +359,10 @@ export class Result<T, E = Error> {
return result;
}

if (isZodResult<Input, U>(result)) {
return fromZodResult(result);
}

if (result instanceof Promise) {
return AsyncResult.wrap(result, (err) => {
logger.warn({ err }, 'Result: unhandled async transform error');
Expand Down Expand Up @@ -383,8 +447,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
return new AsyncResult(Promise.resolve(Result.err(err)));
}

static wrap<T, E = Error, EE = never>(
promise: Promise<Result<T, EE>> | Promise<NonNullable<T>>,
static wrap<T, E = Error, EE = never, Input = any>(
promise:
| Promise<SafeParseReturnType<Input, NonNullable<T>>>
| Promise<Result<T, EE>>
| Promise<RawValue<T>>,
onErr?: (err: NonNullable<E>) => Result<T, E>
): AsyncResult<T, E | EE> {
return new AsyncResult(
Expand All @@ -393,6 +460,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
if (value instanceof Result) {
return value;
}

if (isZodResult<Input, T>(value)) {
return fromZodResult(value);
}

return Result.ok(value);
})
.catch((err) => {
Expand Down Expand Up @@ -469,6 +541,8 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
* Uncaught errors are logged and wrapped to `Result._uncaught()`,
* which leads to re-throwing them in `unwrap()`.
*
* Zod `.safeParse()` results are converted automatically.
*
* ```ts
*
* const { val, err } = await Result.wrap(
Expand All @@ -485,25 +559,33 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
transform<U, EE>(
fn: (value: NonNullable<T>) => 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>(
fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>>
): AsyncResult<U, E | EE>;
transform<U>(
fn: (value: NonNullable<T>) => Promise<NonNullable<U>>
): AsyncResult<U, E>;
transform<U>(
fn: (value: NonNullable<T>) => NonNullable<U>
fn: (value: NonNullable<T>) => Promise<RawValue<U>>
): AsyncResult<U, E>;
transform<U, EE>(
transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): AsyncResult<U, E>;
transform<U, EE, Input = any>(
fn: (
value: NonNullable<T>
) =>
| Result<U, E | EE>
| AsyncResult<U, E | EE>
| SafeParseReturnType<Input, NonNullable<U>>
| Promise<SafeParseReturnType<Input, NonNullable<U>>>
| Promise<Result<U, E | EE>>
| Promise<NonNullable<U>>
| NonNullable<U>
): AsyncResult<U, E | EE> {
| Promise<RawValue<U>>
| RawValue<U>
): AsyncResult<U, E | EE | ZodError<Input>> {
return new AsyncResult(
this.asyncResult
.then((oldResult) => {
Expand All @@ -523,6 +605,10 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> {
return result;
}

if (isZodResult<Input, U>(result)) {
return fromZodResult(result);
}

if (result instanceof Promise) {
return AsyncResult.wrap(result, (err) => {
logger.warn(
Expand Down

0 comments on commit 674c6fc

Please sign in to comment.