简体   繁体   中英

Optional Generic Based On Function Argument

I want to validate my XHR requests via JSON schema. I have validation functions for each response type. If a validation function is specified, the response type from my XHR handler should be extracted from the validation function. If a validation function is not specified, I want the response type to be mixed so that the unknown response data must be dealt with.

So I have this:

type HTTPMethod =
  | 'GET'
  | 'POST'
;

type ResponseValidator<Response> = (mixed) => Response;

type HTTPRequest<
  Method: HTTPMethod,
  Response: mixed,
> = {
  url: string,
  method: Method,
  responseValidator?: ResponseValidator<Response>,
};

type GetRequest<Response = mixed> = HTTPRequest<'GET', Response>;

const defaultValidator: ResponseValidator<mixed> = (data: any) => (data: mixed);

const getRequest= <Response>({
  url,
  responseValidator = defaultValidator,
}: {
  url: string,
  responseValidator?: ResponseValidator<Response>,
}): GetRequest<Response> => ({
    method: 'GET',
    url,
    responseValidator,
  });

Which results in:

23:   responseValidator = defaultValidator,
                          ^ mixed [1] is incompatible with `Response` [2].
References:
19: const defaultValidator: ResponseValidator<mixed> = (data: any) => (data: 
mixed);
                                              ^ [1]
6: type ResponseValidator<Response> = (mixed) => Response;
                                                 ^ [2]

Try Link

I was thinking maybe I could put a default on the Response generic of the function, but flow doesn't seem to support defaults on generics for function, and I doubt that would actually work anyway. Is there a better way to approach this?

Here's what I ended up going with.

I basically sidestepped this issue by, fundamentally, being even more explicit with my types. So now I have two types of request builders, a RequestBuilder :

/**
 * Union of request builders for different HTTP methods.
 */
export type RequestBuilder<UrlParams, Params, SerializedParams> =
  | GetRequestBuilder<UrlParams>
  | DeleteRequestBuilder<UrlParams>
  | PostRequestBuilder<UrlParams, Params, SerializedParams>
  | HeadRequestBuilder<UrlParams, Params, SerializedParams>
;

And a ValidatedRequestBuilder (maybe this should be "validat ing request builder?" Still some details to work out):

/**
 * A RequestBuilder packaged up with a ResponseValidator and a deserializer.
 */
export type ValidatedRequestBuilder<
  UrlParams,
  Params,
  SerializedParams,
  RB: RequestBuilder<UrlParams, Params, SerializedParams>,
  Response,
  Format,
> = {
  requestBuilder: RB,
  responseValidator: ResponseValidator<Response>,
  deserializer: (Response) => Format,
};

And then a union of those two types, AbstractRequestBuilder . You'll see here that this begins to hint at the solution:

/**
 * A RequestBuilder which may or may not be a ValidatedRequestBuilder.
 *
 * This abstracts the API between RequestBuilder and ValidatedRequestBuilder so
 * that they can be used interchangeable (this can be used as if it were a
 * ValidatedRequestBuilder).
 */
export type AbstractRequestBuilder<
  UrlParams,
  Params,
  SerializedParams,
  RB: RequestBuilder<UrlParams, Params, SerializedParams>,
  // it's very important that these default to `mixed` for a regular
  // `RequestBuilder`, this behavior is relied upon when creating a default
  // validator and deserializer for a regular `RequestBuilder`
  Response=mixed,
  Format=mixed,
> =
  | ValidatedRequestBuilder<UrlParams, Params, SerializedParams, RB, Response, Format>
  | RB;

So as far as we're concerned, all request builders are AbstractRequestBuilder s, so when we go to actually build a request from the AbstractRequestBuilder in question, if the underlying request builder is not a ValidatedRequestBuilder , we just implement a default validator and deserializer for it that are basically identity functions that return mixed :

/**
 * Gets a `ValidatedRequest` for the given `AbstractRequestBuilder`,
 * `UrlParams`, and body `Params`.
 *
 * The important thing is that this does the job of differentiating between a
 * `RequestBuilder` and a `ValidatedRequestBuilder` and abstracting behavior.
 * Basically a `ValidatedRequestBuilder` will have a concrete `Response` and
 * `Format`, while a `RequestBuilder` will end up with `mixed`.
 */
export const validatedRequestForBuilder = <
  UrlParams,
  Params,
  SerializedParams: ValidParams,
  Response,
  Format,
  ARB: AbstractRequestBuilder<UrlParams,
    Params,
    SerializedParams,
    RequestBuilder<UrlParams, Params, SerializedParams>,
    Response,
    Format>,
>(
    abstractRequestBuilder: ARB,
    urlParams: UrlParams,
    params: Params,
  ): ValidatedRequest<SerializedParams, Request<SerializedParams>, Response, Format> => (
    typeof abstractRequestBuilder === 'function'
      ? {
        request: (
        abstractRequestBuilder: RequestBuilder<UrlParams,
          Params,
          SerializedParams>
        )(urlParams, params),
        responseValidator: data => ((data: any): Response), // Response is always mixed here
        deserializer: (data: Response) => ((data: any): Format), // Format is always mixed here
      }
      : {
        request: abstractRequestBuilder.requestBuilder(urlParams, params),
        responseValidator: abstractRequestBuilder.responseValidator,
        deserializer: abstractRequestBuilder.deserializer,
      }
  );

So basically every request builder always results in ValidatedRequest s which guarantee some particular deserialized response type, but in some cases, where we passed a regular RequestBuilder and not a ValidatedRequestBuilder , the particular deserialized response type will be mixed . If we don't want to deal with the mixed , then we should specify a validator.

So at the core of this there's a pretty standard pattern that involves being nice and explicit with types and using unions to model alternative scenarios rather than things like option types or optional properties. Unions are much more explicit. I've been thinking about this a lot in terms of things like react prop types. You might have something like:

type PriceType = 'wholesale' | 'retail';

type Props = {
    label: string,
    hasPrice: boolean,
    priceType?: PriceType,
};

Where priceType is required if hasPrice is true , but is not relevant if hasPrice is false. So you look at that and say, well, sometimes I will pass priceType and sometimes I won't so I guess it should be optional. But these are actually two totally separate scenarios that require a union to model correctly:

type PriceType = 'wholesale' | 'retail';

type AlwaysProps = $ReadOnly<{|
  label: string,
|}>;

type Props = $ReadOnly<{|
    ...AlwaysProps,
    hasPrice: true,
    priceType: PriceType,
|}> | $ReadOnly<{|
  ...AlwaysProps,
  hasPrice: false,
|}>;

So I guess the lesson here is when you find yourself using options you should consider whether or not those can or should be more accurately typed as unions.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM