-
-
Notifications
You must be signed in to change notification settings - Fork 534
Union type for error
disappears in typescript-fetch v0.10
#1723
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
Comments
This was an intentional change in It’s now an approach for errors where it’s “take the first you find” because many schemas have a single defined error type, and some other responses being
Unfortunately there’s not really a way to make both behaviors work—either keeping the error union results in many schemas being unusable because of irreconcilable unions. Or keeping this “take the first match” behavior results in some error types being left out. But this change felt safer because the worst case scenario is “represent one shape correctly” rather than “misrepresent all shapes incorrectly.” Removed the bug label, but happy to gather feedback from people, or possible solutions that make both scenarios easier for people. At its root it’s just multiple conflicting approaches to schema design, neither being “right” or “wrong.” |
Would this change effect non-errors? I tried upgrading today and noticed a similar problem. We have an API endpoint that takes a PATCH. On updates it returns an empty 202. However, if a 'complete' property is sent as true, then it returns a 200 with a body of a particular shape. I found that the data property of this response was set to eg: this didn't work
|
This change seems to pretty badly break typing in my case. This is a typical flow: const client = createClient<paths>({ baseUrl: "..." });
const { data, error } = await client.GET("/path");
if (error) {
// handle error
}
// handle data, which is now guaranteed to be the 200 case As of v0.10, Another note, in v0.10, if there is an At this point, I cannot upgrade, frankly, because the typing is totally wonky. Am I doing it wrong? |
Came here for the same reason as @markedwards, There should be a way to have a proper union of returned responses for either, Might be worth returning a |
Is it possible to get some confirmation that this issue indeed needs to be fixed, or if this is the intended behavior moving forward? |
@drwpow Do you have examples of impossible unions? Apart from I am super curious about this because I managed to create unions of all possible success and error responses bodies in OpenAPI-MSW without known edge cases (source code). Further, I added a response helper, which allows constructing responses according the API-Specs, which feels much nicer than dealing with the union from a DX point of view. When creating an extensive example with multiple error and success codes and content-types, I also noticed problems of loosing inference/discrimination on the discriminated I think the best solution that solves everyones problems is some sort of const client = createClient<paths>();
const response = await client.GET("/some/path");
return handleResponse(response, {
// provided data would be typed according to the 200 application/json body.
// I can also imagine just providing a response and the consumer calls `res.json()`,
// as long as it can be type-safe.
"200/json": (data) => return data,
"404/empty": () => throw new Error("Not found.")
"400/text": (text) => throw new Error("Invalid: " + text)
}) I think the code example would not have proper type inference but it provides a rough idea of what I mean with a response helper for handling responses. It would also allow us to delay parsing the response body, so consumers that are not interested in the response result don't have to parse the body in the first place. Are you interested in collaborating on this problem and finding a solution for it? @luchsamapparat and I are really interested in solving this. |
@drwpow any guidance here? openapi-fetch is totally useless to us as of 0.10. The typing is completely broken. Is there any interest in addressing this, or should I start investigating alternatives? Thanks. |
@christoph-fricke I love that approach, and I would gladly welcome solving it from that angle! Thanks for putting so much time and thought into it. Current lift events have prevented me from spending time on this library myself past few weeks, but I’d gladly approve and ship PRs that tackle the solution in the manner outlined |
Hey 👋 I have a proposition type for keeping error types without them intersecting and overriding each other, similar to how @christoph-fricke wanted to have a helper function.
If this is an approach that is alright for you I can make a PR 😄 If I missed something please let me know ! type ErrorReturn<
KP extends keyof paths,
M extends 'put' | 'get' | 'delete' | 'post',
Response extends {
put?: any;
post?: any;
delete?: any;
get?: any;
} = paths[KP],
G extends Expand<{
[Code in keyof Response[M]['responses']]: Response[M]['responses'][Code]['content'];
}> = Expand<{
[Code in keyof Response[M]['responses']]: Response[M]['responses'][Code]['content'];
}>,
> = G;
type ErrorShape = Record<number | string, { [resType: string]: any }>;
type AddMissingKeys<T extends ErrorShape> = Prettify<
{ [K in Exclude<ErrorStatusTuple[number], keyof T>]: {} } & T
>;
// infer prevents "swallowing"
export type Expand<
T extends ErrorShape,
G = AddMissingKeys<T> extends {
'default': infer XX;
499: infer WW;
498: infer VV;
497: infer UU;
451: infer TT;
450: infer SS;
444: infer RR;
431: infer QQ;
430: infer PP;
429: infer OO;
428: infer NN;
427: infer MM;
426: infer LL;
425: infer KK;
424: infer JJ;
423: infer II;
422: infer HH;
421: infer GG;
420: infer FF;
418: infer EE;
417: infer DD;
416: infer CC;
415: infer BB;
414: infer AA;
413: infer Z;
412: infer Y;
411: infer X;
410: infer W;
409: infer V;
408: infer U;
407: infer T;
406: infer S;
405: infer R;
404: infer Q;
403: infer p;
402: infer O;
401: infer N;
400: infer M;
511: infer L;
510: infer K;
508: infer J;
507: infer H;
506: infer G;
505: infer F;
504: infer E;
503: infer D;
502: infer C;
'4xx': infer B;
'5xx': infer A;
}
?
| {
[P in keyof C]: Prettify<
C[P] & { code: ErrorKey<Stringulator<P>, '502'> }
>;
}[keyof C]
| {
[P in keyof D]: Prettify<
D[P] & { code: ErrorKey<Stringulator<P>, '503'> }
>;
}[keyof D]
| {
[P in keyof E]: Prettify<
E[P] & { code: ErrorKey<Stringulator<P>, '504'> }
>;
}[keyof E]
| {
[P in keyof F]: Prettify<
F[P] & { code: ErrorKey<Stringulator<P>, '505'> }
>;
}[keyof F]
| {
[P in keyof G]: Prettify<
G[P] & { code: ErrorKey<Stringulator<P>, '506'> }
>;
}[keyof G]
| {
[P in keyof H]: Prettify<
H[P] & { code: ErrorKey<Stringulator<P>, '507'> }
>;
}[keyof H]
| {
[P in keyof J]: Prettify<
J[P] & { code: ErrorKey<Stringulator<P>, '508'> }
>;
}[keyof J]
| {
[P in keyof K]: Prettify<
K[P] & { code: ErrorKey<Stringulator<P>, '510'> }
>;
}[keyof K]
| {
[P in keyof L]: Prettify<
L[P] & { code: ErrorKey<Stringulator<P>, '511'> }
>;
}[keyof L]
| {
[P in keyof M]: Prettify<
M[P] & { code: ErrorKey<Stringulator<P>, '400'> }
>;
}[keyof M]
| {
[P in keyof N]: Prettify<
N[P] & { code: ErrorKey<Stringulator<P>, '401'> }
>;
}[keyof N]
| {
[P in keyof O]: Prettify<
O[P] & { code: ErrorKey<Stringulator<P>, '402'> }
>;
}[keyof O]
| {
[P in keyof p]: Prettify<
p[P] & { code: ErrorKey<Stringulator<P>, '403'> }
>;
}[keyof p]
| {
[P in keyof Q]: Prettify<
Q[P] & { code: ErrorKey<Stringulator<P>, '404'> }
>;
}[keyof Q]
| {
[P in keyof R]: Prettify<
R[P] & { code: ErrorKey<Stringulator<P>, '405'> }
>;
}[keyof R]
| {
[P in keyof S]: Prettify<
S[P] & { code: ErrorKey<Stringulator<P>, '406'> }
>;
}[keyof S]
| {
[P in keyof T]: Prettify<
T[P] & { code: ErrorKey<Stringulator<P>, '407'> }
>;
}[keyof T]
| {
[P in keyof U]: Prettify<
U[P] & { code: ErrorKey<Stringulator<P>, '408'> }
>;
}[keyof U]
| {
[P in keyof V]: Prettify<
V[P] & { code: ErrorKey<Stringulator<P>, '409'> }
>;
}[keyof V]
| {
[P in keyof W]: Prettify<
W[P] & { code: ErrorKey<Stringulator<P>, '410'> }
>;
}[keyof W]
| {
[P in keyof X]: Prettify<
X[P] & { code: ErrorKey<Stringulator<P>, '411'> }
>;
}[keyof X]
| {
[P in keyof Y]: Prettify<
Y[P] & { code: ErrorKey<Stringulator<P>, '412'> }
>;
}[keyof Y]
| {
[P in keyof Z]: Prettify<
Z[P] & { code: ErrorKey<Stringulator<P>, '413'> }
>;
}[keyof Z]
| {
[P in keyof AA]: Prettify<
AA[P] & { code: ErrorKey<Stringulator<P>, '414'> }
>;
}[keyof AA]
| {
[P in keyof BB]: Prettify<
BB[P] & { code: ErrorKey<Stringulator<P>, '415'> }
>;
}[keyof BB]
| {
[P in keyof CC]: Prettify<
CC[P] & { code: ErrorKey<Stringulator<P>, '416'> }
>;
}[keyof CC]
| {
[P in keyof DD]: Prettify<
DD[P] & { code: ErrorKey<Stringulator<P>, '417'> }
>;
}[keyof DD]
| {
[P in keyof EE]: Prettify<
EE[P] & { code: ErrorKey<Stringulator<P>, '418'> }
>;
}[keyof EE]
| {
[P in keyof FF]: Prettify<
FF[P] & { code: ErrorKey<Stringulator<P>, '420'> }
>;
}[keyof FF]
| {
[P in keyof GG]: Prettify<
GG[P] & { code: ErrorKey<Stringulator<P>, '421'> }
>;
}[keyof GG]
| {
[P in keyof HH]: Prettify<
HH[P] & { code: ErrorKey<Stringulator<P>, '422'> }
>;
}[keyof HH]
| {
[P in keyof II]: Prettify<
II[P] & { code: ErrorKey<Stringulator<P>, '423'> }
>;
}[keyof II]
| {
[P in keyof JJ]: Prettify<
JJ[P] & { code: ErrorKey<Stringulator<P>, '424'> }
>;
}[keyof JJ]
| {
[P in keyof KK]: Prettify<
KK[P] & { code: ErrorKey<Stringulator<P>, '425'> }
>;
}[keyof KK]
| {
[P in keyof LL]: Prettify<
LL[P] & { code: ErrorKey<Stringulator<P>, '426'> }
>;
}[keyof LL]
| {
[P in keyof MM]: Prettify<
MM[P] & { code: ErrorKey<Stringulator<P>, '427'> }
>;
}[keyof MM]
| {
[P in keyof NN]: Prettify<
NN[P] & { code: ErrorKey<Stringulator<P>, '428'> }
>;
}[keyof NN]
| {
[P in keyof OO]: Prettify<
OO[P] & { code: ErrorKey<Stringulator<P>, '429'> }
>;
}[keyof OO]
| {
[P in keyof PP]: Prettify<
PP[P] & { code: ErrorKey<Stringulator<P>, '430'> }
>;
}[keyof PP]
| {
[P in keyof QQ]: Prettify<
QQ[P] & { code: ErrorKey<Stringulator<P>, '431'> }
>;
}[keyof QQ]
| {
[P in keyof RR]: Prettify<
RR[P] & { code: ErrorKey<Stringulator<P>, '444'> }
>;
}[keyof RR]
| {
[P in keyof SS]: Prettify<
SS[P] & { code: ErrorKey<Stringulator<P>, '450'> }
>;
}[keyof SS]
| {
[P in keyof TT]: Prettify<
TT[P] & { code: ErrorKey<Stringulator<P>, '451'> }
>;
}[keyof TT]
| {
[P in keyof UU]: Prettify<
UU[P] & { code: ErrorKey<Stringulator<P>, '497'> }
>;
}[keyof UU]
| {
[P in keyof VV]: Prettify<
VV[P] & { code: ErrorKey<Stringulator<P>, '498'> }
>;
}[keyof VV]
| {
[P in keyof WW]: Prettify<
WW[P] & { code: ErrorKey<Stringulator<P>, '499'> }
>;
}[keyof WW]
| {
[P in keyof B]: Prettify<
B[P] & { code: ErrorKey<Stringulator<P>, '4xx'> }
>;
}[keyof B]
| {
[P in keyof A]: Prettify<
A[P] & { code: ErrorKey<Stringulator<P>, '5xx'> }
>;
}[keyof A]
| {
[P in keyof XX]: Prettify<
XX[P] & { code: ErrorKey<Stringulator<P>, 'default'> }
>;
}[keyof XX]
:
never,
> = G;
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
type Stringulator<T extends string | number | symbol> = T extends number
? `${T}`
: T extends string
? T
: 'was a symbol';
type ErrorKey<
ContentType extends string,
RawCode extends string | number | symbol,
Code extends string = Stringulator<RawCode>,
> = ContentType extends `${infer _}/${infer Type}` ? `${Code}/${Type}` : never;
type ErrorStatusTuple = [
499,
498,
497,
451,
450,
444,
431,
430,
429,
428,
427,
426,
425,
424,
423,
422,
421,
420,
418,
417,
416,
415,
414,
413,
412,
411,
410,
409,
408,
407,
406,
405,
404,
403,
402,
401,
400,
511,
510,
508,
507,
506,
505,
504,
503,
502,
'4xx',
'5xx',
'default',
]; |
Per #1723 (comment), curious if there's been any progress on a helper? That sounds like an ideal solution, thanks for suggesting it! |
This is also a show stopper for us |
FWIW, I wound up working around this by writing my own fetch client + helper that can discriminate on HTTP codes and types its This is far from polished for general use, and there are some parts of the OpenAPI 3.x spec that it likely won't handle... but it works for my specific use-case of pairing with a Poem OpenAPI server. Here's the code, if this helps anyone: types.tsNote: import { paths } from "./schema.gen";
// Methods that are allowed by this spec.
export type Method = "get" | "post" | "put" | "delete";
// Filter down the list of paths to only those that support
// the given method.
export type Paths<M extends Method> = {
[K in keyof paths]: paths[K] extends { [key in M]: any } ? K : never;
}[keyof paths];
// Get the request body type for a given path and method. This is unlikely
// to apply to all paths, so we need to use a conditional type to check
// if the path supports a request body.
export type RequestBody<
M extends Method,
P extends Paths<M>,
> = paths[P][M] extends {
requestBody: {
content: { [key in string]: infer R };
};
}
? R
: void;
// Define the "inner" parameters that can be passed to a request. This
// includes param values for the query, header, path, and cookie.
export type InnerParams = {
query?: object;
header?: object;
path?: object;
cookie?: object;
};
// Get the parameters for a given path and method. This is unlikely
// to apply to all paths, so we need to use a conditional type to check
// if the path supports parameters.
export type Params<M extends Method, P extends Paths<M>> = paths[P][M] extends {
parameters: infer Params extends InnerParams;
}
? Params
: never;
// Extract the `responses` key on a path and method and return a map of the
// status codes -> content/headers. We can use this to narrow further to headers
// and content for a specific status code.
export type Responses<
M extends Method,
P extends Paths<M>,
> = paths[P][M] extends {
responses: infer R extends {};
}
? R
: never;
// Extract the headers for a given status code.
export type HeadersByStatus<
M extends Method,
P extends Paths<M>,
S extends number,
> =
Responses<M, P> extends {
[key in S]: {
headers?: infer H;
};
}
? H
: never;
// Extract the content data for a given status code.
export type ContentByStatus<
M extends Method,
P extends Paths<M>,
S extends number,
> =
Responses<M, P> extends {
[key in S]: {
content?: { [key in string]: infer C };
};
}
? C
: never;
// The `data` object contains the headers and content for a given status code.
export type DataByStatus<
M extends Method,
P extends Paths<M>,
S extends number,
> = {
headers: HeadersByStatus<M, P, S>;
content: ContentByStatus<M, P, S>;
};
// A full response contains the status code (that we can discriminate on) and
// the header + content `data` object. We can use the `checkStatus()` function to
// narrow the type of the `data` object based on the status code.
export type Result<M extends Method, P extends Paths<M>, S extends number> = {
status: S;
contentType: string | null;
data: DataByStatus<M, P, S>;
};
// Params for the `handleRequest` function
export type HandleRequestParams<M extends Method, P extends Paths<M>> = {
path: P;
method: Uppercase<M>;
params?: InnerParams;
body?: object;
requestInit?: RequestInit;
};
// Type for a function that modifies the request before it's sent. This can
// be used to add headers for auth or other purposes.
export type ModifyRequestFunc = (req: RequestInit) => RequestInit;
//
// Argument types for `client` functions.
//
// GET
export type GetArgs<P extends Paths<"get">> =
Params<"get", P> extends {
[K in keyof Params<"get", P>]: never;
}
? [path: P]
: [path: P, params: Params<"get", P>];
// POST
export type PostArgs<P extends Paths<"post">> =
Params<"post", P> extends {
[K in keyof Params<"post", P>]: never;
}
? [path: P, body: RequestBody<"post", P>]
: [path: P, body: RequestBody<"post", P>, params: Params<"post", P>]; client.tsimport type {
Method,
Paths,
HandleRequestParams,
GetArgs,
PostArgs,
Result,
Responses,
ModifyRequestFunc,
} from "./types";
const CONTENT_TYPE_HEADER = "Content-Type";
const APPLICATION_JSON = "application/json";
// Create a type-safe client using the OpenAPI schema and fetch
export function createClient(baseURL: string) {
// Start with an identity function for modifying the requests.
// This will be used by default, and can be overridden by the user.
let modifyRequestFunc: ModifyRequestFunc = (req) => req;
// Define a function to handle requests, closing over `baseURL`
const handleRequest = async <M extends Method, P extends Paths<M>>({
path,
method,
params = {},
body,
requestInit = {},
}: HandleRequestParams<M, P>): Promise<Result<M, P, number>> => {
// Replace path parameters with values
let substitutedPath = Object.entries(params?.path || {}).reduce(
(acc, [key, value]) => {
return acc.replace(`{${key}}`, value);
},
path as string
);
// Build the request URL
let url = new URL(baseURL + substitutedPath);
// Add the search params to the URL for `GET` requests
if (requestInit.method === "GET" && params.query) {
Object.entries(params.query).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
// Build the final request by merging request opts
let finalRequestInit = modifyRequestFunc({ ...requestInit, method });
// If we have a body, add it to the request
if (body) {
finalRequestInit.body = JSON.stringify(body);
// Add headers to the request
finalRequestInit.headers = {
...finalRequestInit.headers,
[CONTENT_TYPE_HEADER]: APPLICATION_JSON,
};
}
// Make the request and infer the response based on the expected code.
// We'll leave the caller to catch exceptions and handle them as needed.
const res = await fetch(url, finalRequestInit);
// Initialise a data object to hold the headers and the content.
// We're trusting here that this maps 1:1 to what's in the spec!
//
// For now, we'll type this as `any` to build the object dynamically
// and we'll narrow it later based on the content type.
let data = {
headers: {},
content: null,
} as any;
// Insert the headers.
res.headers.forEach((value, key) => {
data.headers[key] = value;
});
// Parse the response based on the content type.
const contentType = res.headers.get(CONTENT_TYPE_HEADER);
// If we *do* have a content type header, we'll assume it's
// either JSON or plain text we need to parse.
if (contentType) {
data.content = await (async () => {
if (contentType.includes(APPLICATION_JSON)) {
return res.json();
} else {
return res.text();
}
})();
}
// Return a mapping of status -> content. Initially, this
// will map to `number`, but this will be narrowed later with
// a call to `checkStatus` to get the relevant content.
return {
status: res.status,
contentType,
data,
};
};
return {
// Allow the user to modify the request before it's sent.
modifyRequest(func: (req: RequestInit) => RequestInit) {
modifyRequestFunc = func;
},
// GET paths
get<P extends Paths<"get">>(...args: GetArgs<P>) {
return handleRequest<"get", P>({
path: args[0],
method: "GET",
params: args[1],
});
},
// POST paths
post<P extends Paths<"post">>(...args: PostArgs<P>) {
return handleRequest<"post", P>({
path: args[0],
method: "POST",
body: args[1]!,
params: args[2],
});
},
};
}
// Narrow the type of a response based on the status code.
export function checkStatus<
M extends Method,
P extends Paths<M>,
const S extends keyof Responses<M, P> & number,
>(res: Result<M, P, any>, status: S): res is Result<M, P, S> {
return res.status === status;
} And an example of how it can be used... // This example code is extracted from a Next.js submit
// handler I wrote for a registration form.
// Instantiate the client. I have two methods here -- one for
// server-side requests and one for client-side requests, which
// inject HTTP headers for each environment accordingly.
const client = clientApi();
// Type safe. `client.post` will only accept known string paths,
// and `data` must match the body request.
const res = await client.post("/auth/register", data);
// We check use the `checkStatus` helper to narrow down the
// response type. This accepts known key constants -- e.g. "300" here
// would be a type error. Since this is coded using `const T` on the
// function, this provides implicit narrowing on `res.data.*`
if (checkStatus(res, 200)) {
setEmailConfirmation(res.data.content);
return;
}
// This is the crux of why I need this: a 422 response in my server
// API typically contains a union of possible types (mapped using Rust
// enums), so we need to a) narrow based on the HTTP code and b) further
// narrow based on the content (in this case, the `type` key)
if (checkStatus(res, 422)) {
const { content } = res.data;
if (content.type === "RegisterFailed") { // <-- fully known, typed!
switch (content.reason) {
case "EmailExists": // <-- another discriminant.
setError("email", { message: "Email already exists" });
return;
}
}
// If .type != "RegisterFailed", Typescript knows it must therefore
// be an "InputErrors" which is a typed array of field-mapping errors
content.errors.forEach(({ path, message }) => {
setError(path, { message });
});
} |
Thanks all for giving additional feedback! I agree now that we made the wrong call on this.
If you look at the original issue, it was less that the raw schema object type union was impossible, and more that in an API serving multiple content types often the desired one will get dropped. But the change didn’t necessarily fix this at a core level. Perhaps we do something like allow an optional generic param to fix that, e.g; client<paths, "application/ nd.api+json"> Anyways, again, thanks all for sharing feedback and providing solutions.@Twiggeh, I think that would work, and we’d accept a PR for that 🙂. |
As an aside, I think this project is probably reaching the state where we could do some RFCs for upcoming breaking changes. We’ll certainly have one for 1.0, and I think this pretty much counts as an RFC for reverting this change. I don’t think RFCs magically notify the right people every time who are key decision-drivers. But they can highlight 2 things:
|
Any ideas on a timeline for repairing the situation? We're still pinned to <0.10.0. Is it possible to revert this change and get back to the previous behavior, and then work out a better solution from there? |
same here, were are also pinned to const response = await apiClient.POST('/foo');
handleErrorResponse(response, {
403: MissingPermissionsError,
409: FooAlreadyExistsError,
});
return response.data; Updating to |
We also have to revert back to 0.9.8 because we need to be able to handle different response bodies for different status codes. |
Sorry, I completely missed that email, glad this is resolved now 😄 |
I'm still experiencing a weird behavior with
But if I check data like |
I'm having the same issue as @markedwards. |
Should we create a new issue for this at this point? I still am unable to update from <0.10.0. |
Same issue here |
We can reopen to address this. Thanks for chiming in! We’ll prioritize a fix for this. |
Still experiencing the issue as of |
@markedwards same here. Is anyone working on this? |
Downgrading to 0.12.4 fixed the type issue for me. |
Description
The union type for the error object returned by fetch has changed in typescript-fetch v0.10 and later versions.
Reproduction
I updated typescript-fetch to v0.10.1.
After the update, the type inference for the error object returned by fetch changed as follows:
Before v0.9.7
const error: { message: string } | Record<string, never> | undefined
v0.10.0 and Later
const error: { message: string } | undefined
OpenAPI YAML
I change the error response based on the type of error. For example, in the case of a 404 error, only a message is returned. For a 422 error, error messages are returned for each field.
Expected result
It would be great if the error type could be the same as in previous versions:
const error: { message: string } | Record<string, never> | undefined
Checklist
The text was updated successfully, but these errors were encountered: