Skip to content

Commit 2ee7667

Browse files
committed
feat: make responses discriminated unions for stronger type safety while not throwing for invalid status codes
1 parent 8671523 commit 2ee7667

File tree

7 files changed

+388
-202
lines changed

7 files changed

+388
-202
lines changed

lib/build/index.ts

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { RequestRoute, Server } from "@hapi/hapi";
22
import Joi from "joi";
33
import type ts from "typescript";
4-
import { addSyntheticLeadingComment, factory as f } from "typescript";
4+
import { addSyntheticLeadingComment } from "typescript";
55

66
import type { ExtendedObjectSchema, ExtendedSchema } from "./joi.ts";
77
import {
8+
importDeclaration,
89
typeAliasDeclaration,
910
typeLiteralNode,
11+
typeReferenceNode,
12+
unionTypeNode,
1013
} from "./nodes.ts";
1114
import {
1215
generateType,
@@ -51,9 +54,11 @@ export function getTypeName(routeName: string, suffix: string): string {
5154
}
5255

5356
export function generateClientType(server: Server) {
54-
const routeMap: Record<string, Record<string, Record<string, { name: string; required: boolean }>>> = {};
57+
const routeList: ts.TypeNode[] = [];
5558
const statements: ts.Statement[] = [];
5659

60+
statements.push(importDeclaration(["Route", "StatusCode"], "@code4rena/typed-client", true));
61+
5762
for (const route of server.table()) {
5863
const routeName = getRouteName(route);
5964
const routeOptions: Record<string, { name: string; required: boolean }> = {};
@@ -108,40 +113,62 @@ export function generateClientType(server: Server) {
108113
statements.push(typeAliasDeclaration(payloadTypeName, generateType(route.settings.validate.payload as ExtendedObjectSchema), true));
109114
}
110115

116+
// the result type is the type of the actual response from the api
117+
// const resultTypeName = getTypeName(routeName, "result");
118+
// the response type is the result wrapped in the higher level object including url, status, etc.
111119
const responseTypeName = getTypeName(routeName, "response");
112-
const responseValidator = Object.entries(route.settings.response?.status ?? {})
113-
.filter(([code]) => +code >= 200 && +code < 300)
114-
.map(([_, schema]) => schema)[0] ?? Joi.any();
120+
const matchedCodes: string[] = [];
121+
const responseTypeList: string[] = [];
122+
// first, we iterate our response validators and get everything that has a specific response code
123+
for (const [code, schema] of Object.entries(route.settings.response?.status ?? {})) {
124+
matchedCodes.push(code);
125+
126+
const responseCodeTypeName = getTypeName(responseTypeName, code);
127+
const responseNode = typeLiteralNode({
128+
status: { name: `${code}`, required: true },
129+
ok: { name: (+code >= 200 && +code < 300) ? "true" : "false", required: true },
130+
headers: { name: "Headers", required: true },
131+
url: { name: "string", required: true },
132+
data: { node: generateType(schema as ExtendedObjectSchema), required: isRequired(schema as ExtendedSchema) },
133+
});
134+
statements.push(typeAliasDeclaration(responseCodeTypeName, responseNode, true));
135+
responseTypeList.push(responseCodeTypeName);
136+
}
137+
138+
// now we insert a final response type where the status code is Exclude<StatusCodes, matchedCodes>
139+
const unknownResponseName = getTypeName(responseTypeName, "Unknown");
140+
const unknownResponseNode = typeLiteralNode({
141+
status: {
142+
name: matchedCodes.length
143+
// HACK: this could probably build a real node with a real union passed to a real generic
144+
? `Exclude<StatusCode, ${matchedCodes.join(" | ")}>`
145+
: "StatusCode",
146+
required: true,
147+
},
148+
ok: { name: "boolean", required: true },
149+
headers: { name: "Headers", required: true },
150+
url: { name: "string", required: true },
151+
data: { name: "unknown", required: false },
152+
});
153+
statements.push(typeAliasDeclaration(unknownResponseName, unknownResponseNode, true));
154+
responseTypeList.push(unknownResponseName);
155+
156+
const responseUnionType = unionTypeNode(responseTypeList.map((responseType) => typeReferenceNode(responseType)));
157+
statements.push(typeAliasDeclaration(responseTypeName, responseUnionType, true));
158+
115159
routeOptions.response = {
116160
name: responseTypeName,
117161
required: true,
118162
};
119-
statements.push(typeAliasDeclaration(responseTypeName, generateType(responseValidator as ExtendedObjectSchema), true));
120163

121-
routeMap[route.method.toUpperCase()] ??= {};
122-
routeMap[route.method.toUpperCase()][route.path] = routeOptions;
164+
const routeType = typeReferenceNode("Route", route.method.toUpperCase(), route.path, typeLiteralNode(routeOptions));
165+
routeList.push(routeType);
123166
}
124167

125-
const clientTypeNode = f.createTypeLiteralNode(Object.entries(routeMap).map(([method, route]) => {
126-
return f.createPropertySignature(
127-
[],
128-
f.createStringLiteral(method),
129-
undefined,
130-
f.createTypeLiteralNode(Object.entries(route).map(([path, options]) => {
131-
return f.createPropertySignature(
132-
[],
133-
f.createStringLiteral(path),
134-
undefined,
135-
typeLiteralNode(options),
136-
);
137-
})));
138-
}));
139-
140-
statements.push(typeAliasDeclaration("RouteMap", clientTypeNode, true));
141-
142-
addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/indent ", true);
143-
addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/quote-props ", true);
144-
addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/comma-dangle ", true);
168+
statements.push(typeAliasDeclaration("Routes", unionTypeNode(routeList), true));
169+
170+
// throw a comment at the front to disable eslint for the file since it may not align with formatting
171+
addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable ", true);
145172

146173
const sourceFile = factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None);
147174
const printer = createPrinter();

lib/build/nodes.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
QuestionToken,
1010
Statement,
1111
TypeNode,
12+
TypeParameterDeclaration,
1213
} from "typescript";
1314
import {
1415
EmitHint,
@@ -54,15 +55,13 @@ export function dedupeNodes(nodes: TypeNode[]): TypeNode[] {
5455
});
5556
}
5657

57-
export function importDeclaration(name: string, from: string, typeOnly?: boolean) {
58+
export function importDeclaration(name: string | string[], from: string, typeOnly?: boolean) {
5859
return f.createImportDeclaration(
5960
undefined,
6061
f.createImportClause(
6162
typeOnly ?? false,
6263
undefined,
63-
f.createNamedImports([
64-
f.createImportSpecifier(false, undefined, f.createIdentifier(name)),
65-
]),
64+
f.createNamedImports([...name].map((name) => f.createImportSpecifier(false, undefined, f.createIdentifier(name)))),
6665
),
6766
f.createStringLiteral(from),
6867
);
@@ -81,8 +80,12 @@ export function exportDeclaration(name: string, from: string) {
8180
);
8281
}
8382

84-
export function typeAliasDeclaration(name: string, node: TypeNode, exported: boolean = false) {
85-
return f.createTypeAliasDeclaration(exported ? [exportToken()] : [], name, undefined, node);
83+
export function typeAliasDeclaration(name: string, node: TypeNode, exported: boolean = false, types: TypeParameterDeclaration[] = []) {
84+
return f.createTypeAliasDeclaration(exported ? [exportToken()] : [], name, types, node);
85+
}
86+
87+
export function typeParameterDeclaration(name: string, constraint: TypeNode = undefined, def: TypeNode = undefined) {
88+
return f.createTypeParameterDeclaration(undefined, name, constraint, def);
8689
}
8790

8891
export function questionToken(schema?: Schema): QuestionToken | undefined {
@@ -149,21 +152,29 @@ export function unknownTypeNode() {
149152
return f.createKeywordTypeNode(SyntaxKind.UnknownKeyword);
150153
}
151154

152-
export function typeLiteralNode(props: Record<string, { name: string; required: boolean }>) {
155+
type typeLiteralNodeProps = Record<string, {
156+
required: boolean;
157+
types?: TypeNode[];
158+
} & ({ name: string; node?: never } | { name?: never; node: TypeNode })>;
159+
export function typeLiteralNode(props: typeLiteralNodeProps) {
153160
return f.createTypeLiteralNode(Object.entries(props).map(([key, prop]) => {
154161
return f.createPropertySignature(
155162
[],
156-
f.createStringLiteral(key),
163+
f.createIdentifier(key),
157164
prop.required ? undefined : questionToken(),
158-
f.createTypeReferenceNode(f.createIdentifier(prop.name)),
165+
typeof prop.name === "string"
166+
? f.createTypeReferenceNode(f.createIdentifier(prop.name), prop.types)
167+
: prop.node,
159168
);
160169
}));
161170
}
162171

163-
export function typeReferenceNode(name: string, ...typeArguments: string[]) {
172+
export function typeReferenceNode(name: string, ...typeArguments: (string | TypeNode)[]) {
164173
return f.createTypeReferenceNode(
165174
f.createIdentifier(name),
166-
typeArguments.map((typeArgument) => f.createTypeReferenceNode(f.createIdentifier(typeArgument))),
175+
typeArguments.map((typeArgument) => typeof typeArgument === "string"
176+
? f.createLiteralTypeNode(f.createStringLiteral(typeArgument))
177+
: typeArgument),
167178
);
168179
}
169180

@@ -173,13 +184,13 @@ export function objectTypeNode(props: Property[] | Map<string, Schema>, options:
173184
const mappedProps = Array.isArray(props)
174185
? props.map((prop) => f.createPropertySignature(
175186
[],
176-
f.createStringLiteral(prop.key),
187+
f.createIdentifier(prop.key),
177188
questionToken(prop.schema),
178189
generateType(prop.schema, _options)),
179190
)
180191
: Array.from(props.entries()).map(([key, schema]) => f.createPropertySignature(
181192
[],
182-
f.createStringLiteral(key),
193+
f.createIdentifier(key),
183194
questionToken(schema),
184195
generateType(schema, _options),
185196
));

0 commit comments

Comments
 (0)