Skip to content

Commit b4ca8fc

Browse files
konardclaude
andcommitted
fix(strict-types): address blocking review requirements for PR#3
Changes to fix reviewer feedback: 1. **Generic Is2xx type** (section 2.2): - Replaced hardcoded 2xx status list with template literal type - `Is2xx<S> = \`${S}\` extends \`2${string}\` ? true : false` - Added test fixture with non-standard 250 status to prove genericity 2. **Request-side type enforcement** (section 2.1): - Added PathParamsFor, QueryParamsFor, RequestBodyFor types - Added RequestOptionsFor to derive request options from operation - Updated StrictApiClient to use PathsForMethod constraints - Path/method now determines operation, which determines params/query/body 3. **any/unknown policy** (section 2.3): - Consolidated all type casts into axioms.ts - Added asStrictApiClient, asStrictRequestInit helpers - Added ClassifyFn type for dispatcher functions - Added lint:types script to enforce policy 4. **Compile-time tests** (section 2.4): - Added type tests for Is2xx with 250 status - Added PathsForMethod constraint tests - Used expectTypeOf().not.toExtend() for negative tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7f7d79e commit b4ca8fc

10 files changed

Lines changed: 638 additions & 151 deletions

File tree

packages/app/examples/strict-error-handling.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ export const createPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpC
9191
"/pets",
9292
dispatchercreatePet,
9393
{
94-
body: JSON.stringify({ name: "Fluffy", tag: "cat" }),
95-
headers: { "Content-Type": "application/json" }
94+
// Body can be typed object - client will auto-stringify and set Content-Type
95+
body: { name: "Fluffy", tag: "cat" }
9696
}
9797
)
9898

packages/app/examples/test-create-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ const createPetExample = Effect.gen(function*() {
127127
"/pets",
128128
dispatchercreatePet,
129129
{
130-
body: JSON.stringify(newPet),
131-
headers: { "Content-Type": "application/json" }
130+
// Typed body - client will auto-stringify and set Content-Type
131+
body: newPet
132132
}
133133
)
134134

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"lint": "npx @ton-ai-core/vibecode-linter src/",
1313
"lint:tests": "npx @ton-ai-core/vibecode-linter tests/",
1414
"lint:effect": "npx eslint --config eslint.effect-ts-check.config.mjs .",
15+
"lint:types": "./scripts/lint-types.sh",
1516
"check": "pnpm run typecheck",
1617
"prestart": "pnpm run build",
1718
"start": "node dist/main.js",

packages/app/scripts/lint-types.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/bin/bash
2+
# CHANGE: Add anti-any/unknown lint check script
3+
# WHY: Enforce type safety policy per blocking review requirements
4+
# QUOTE(ТЗ): "Автоматическая проверка \"нет any/unknown\" - добавить отдельную команду"
5+
# REF: PR#3 blocking review section 4.4
6+
7+
# Exit on error
8+
set -e
9+
10+
echo "Checking for any/unknown usage outside axioms.ts..."
11+
12+
# Files allowed to contain any/unknown (Variant B policy)
13+
ALLOWED_FILES=(
14+
"src/core/axioms.ts"
15+
)
16+
17+
# Pattern to find problematic any/unknown usage
18+
# Excludes:
19+
# - Type comments (/* any */)
20+
# - JSDoc type comments (/** @type {any} */)
21+
# - conditional extends unknown (idiomatic TypeScript)
22+
PATTERN='(: any\b|as any\b|\bunknown\b)'
23+
24+
# Find all TypeScript files in src, excluding allowed files
25+
FOUND_VIOLATIONS=""
26+
for file in $(find src -name "*.ts" -type f); do
27+
# Check if file is in allowed list
28+
IS_ALLOWED=false
29+
for allowed in "${ALLOWED_FILES[@]}"; do
30+
if [[ "$file" == *"$allowed"* ]]; then
31+
IS_ALLOWED=true
32+
break
33+
fi
34+
done
35+
36+
if [ "$IS_ALLOWED" = false ]; then
37+
# Search for violations, excluding conditional type patterns
38+
MATCHES=$(grep -nE "$PATTERN" "$file" 2>/dev/null | grep -vE 'extends.*unknown|Record<string, unknown>' || true)
39+
if [ -n "$MATCHES" ]; then
40+
FOUND_VIOLATIONS="$FOUND_VIOLATIONS\n$file:\n$MATCHES\n"
41+
fi
42+
fi
43+
done
44+
45+
if [ -n "$FOUND_VIOLATIONS" ]; then
46+
echo -e "\n❌ Found any/unknown usage outside allowed files:"
47+
echo -e "$FOUND_VIOLATIONS"
48+
echo ""
49+
echo "Allowed files: ${ALLOWED_FILES[*]}"
50+
echo "Please move type casts to axioms.ts or eliminate the usage."
51+
exit 1
52+
else
53+
echo "✅ No any/unknown violations found!"
54+
exit 0
55+
fi

packages/app/src/core/api-client/strict-types.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,84 @@ export type OperationFor<
4040
*/
4141
export type ResponsesFor<Op> = Op extends { responses: infer R } ? R : never
4242

43+
// ============================================================================
44+
// Request-side typing (path/method → params/query/body)
45+
// ============================================================================
46+
47+
/**
48+
* Extract path parameters from operation
49+
*
50+
* @pure true - compile-time only
51+
* @invariant Returns path params type or undefined if none
52+
*/
53+
export type PathParamsFor<Op> = Op extends { parameters: { path: infer P } }
54+
? P extends Record<string, infer V> ? Record<string, V>
55+
: never
56+
: undefined
57+
58+
/**
59+
* Extract query parameters from operation
60+
*
61+
* @pure true - compile-time only
62+
* @invariant Returns query params type or undefined if none
63+
*/
64+
export type QueryParamsFor<Op> = Op extends { parameters: { query?: infer Q } } ? Q
65+
: undefined
66+
67+
/**
68+
* Extract request body type from operation
69+
*
70+
* @pure true - compile-time only
71+
* @invariant Returns body type or undefined if no requestBody
72+
*/
73+
export type RequestBodyFor<Op> = Op extends { requestBody: { content: infer C } }
74+
? C extends { "application/json": infer J } ? J
75+
: C extends { [key: string]: infer V } ? V
76+
: never
77+
: undefined
78+
79+
/**
80+
* Check if path params are required
81+
*
82+
* @pure true - compile-time only
83+
*/
84+
85+
export type HasRequiredPathParams<Op> = Op extends { parameters: { path: infer P } }
86+
? P extends Record<PropertyKey, string | number | boolean> ? keyof P extends never ? false : true
87+
: false
88+
: false
89+
90+
/**
91+
* Check if request body is required
92+
*
93+
* @pure true - compile-time only
94+
*/
95+
export type HasRequiredBody<Op> = Op extends { requestBody: infer RB } ? RB extends { content: object } ? true
96+
: false
97+
: false
98+
99+
/**
100+
* Build request options type from operation with all constraints
101+
* - params: required if path has required parameters
102+
* - query: optional, typed from operation
103+
* - body: required if operation has requestBody (accepts typed object OR string)
104+
*
105+
* For request body:
106+
* - Users can pass either the typed object (preferred, for type safety)
107+
* - Or a pre-stringified JSON string with headers (for backwards compatibility)
108+
*
109+
* @pure true - compile-time only
110+
* @invariant Options type is fully derived from operation definition
111+
*/
112+
export type RequestOptionsFor<Op> =
113+
& (HasRequiredPathParams<Op> extends true ? { readonly params: PathParamsFor<Op> }
114+
: { readonly params?: PathParamsFor<Op> })
115+
& (HasRequiredBody<Op> extends true ? { readonly body: RequestBodyFor<Op> | BodyInit }
116+
: { readonly body?: RequestBodyFor<Op> | BodyInit })
117+
& { readonly query?: QueryParamsFor<Op> }
118+
& { readonly headers?: HeadersInit }
119+
& { readonly signal?: AbortSignal }
120+
43121
/**
44122
* Extract status codes from responses
45123
*
@@ -128,29 +206,40 @@ type AllResponseVariants<Responses> = StatusCodes<Responses> extends infer Statu
128206
: never
129207
: never
130208

209+
/**
210+
* Generic 2xx status detection without hardcoding
211+
* Uses template literal type to check if status string starts with "2"
212+
*
213+
* Works with any 2xx status including non-standard ones like 250.
214+
*
215+
* @pure true - compile-time only
216+
* @invariant Is2xx<S> = true ⟺ 200 ≤ S < 300
217+
*/
218+
export type Is2xx<S extends string | number> = `${S}` extends `2${string}` ? true : false
219+
131220
/**
132221
* Filter response variants to success statuses (2xx)
222+
* Uses generic Is2xx instead of hardcoded status list.
133223
*
134224
* @pure true - compile-time only
135-
* @invariant ∀ v ∈ SuccessVariants: v.status ∈ [200..299]
225+
* @invariant ∀ v ∈ SuccessVariants: Is2xx<v.status> = true
136226
*/
137227
export type SuccessVariants<Responses> = AllResponseVariants<Responses> extends infer V
138-
? V extends ResponseVariant<Responses, infer S, infer CT>
139-
? S extends 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 ? ResponseVariant<Responses, S, CT>
228+
? V extends ResponseVariant<Responses, infer S, infer CT> ? Is2xx<S> extends true ? ResponseVariant<Responses, S, CT>
140229
: never
141230
: never
142231
: never
143232

144233
/**
145234
* Filter response variants to error statuses (non-2xx from schema)
146235
* Returns HttpErrorResponseVariant with `_tag: "HttpError"` for discrimination.
236+
* Uses generic Is2xx instead of hardcoded status list.
147237
*
148238
* @pure true - compile-time only
149-
* @invariant ∀ v ∈ HttpErrorVariants: v.status ∉ [200..299] ∧ v.status ∈ Schema ∧ v._tag = "HttpError"
239+
* @invariant ∀ v ∈ HttpErrorVariants: Is2xx<v.status> = false ∧ v.status ∈ Schema ∧ v._tag = "HttpError"
150240
*/
151241
export type HttpErrorVariants<Responses> = AllResponseVariants<Responses> extends infer V
152-
? V extends ResponseVariant<Responses, infer S, infer CT>
153-
? S extends 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226 ? never
242+
? V extends ResponseVariant<Responses, infer S, infer CT> ? Is2xx<S> extends true ? never
154243
: HttpErrorResponseVariant<Responses, S, CT>
155244
: never
156245
: never

packages/app/src/core/axioms.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,32 @@ export const asDispatcher = <Responses>(
104104
* @pure true
105105
*/
106106
export const asStrictRequestInit = <T>(config: object): T => config as T
107+
108+
/**
109+
* Classifier function type for dispatcher creation
110+
* AXIOM: Classify function returns Effect with heterogeneous union types
111+
*
112+
* This type uses `unknown` to allow the classify function to return
113+
* heterogeneous Effect unions from switch statements. The actual types
114+
* are enforced by the generated dispatcher code.
115+
*
116+
* @pure true
117+
*/
118+
export type ClassifyFn = (
119+
status: number,
120+
contentType: string | undefined,
121+
text: string
122+
) => Effect.Effect<unknown, unknown>
123+
124+
/**
125+
* Cast internal client implementation to typed StrictApiClient
126+
* AXIOM: Client implementation correctly implements all method constraints
127+
*
128+
* This cast is safe because:
129+
* 1. StrictApiClient type enforces path/method constraints at call sites
130+
* 2. The runtime implementation correctly builds requests for any path/method
131+
* 3. Type checking happens at the call site, not in the implementation
132+
*
133+
* @pure true
134+
*/
135+
export const asStrictApiClient = <T>(client: object): T => client as T

0 commit comments

Comments
 (0)