prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #8 from tylersayshi/infer-refs

Infer refs

authored by

tyler and committed by
GitHub
8303c7e0 fac17f25

+405 -499
-257
notebook/skip-uniontotuple-optimization.md
··· 1 - # Skip UnionToTuple When Empty - Optimization Notes 2 - 3 - ## Starting Point 4 - 5 - **Initial Benchmark Results (from todo.md):** 6 - 7 - - Simple object (2 properties): **578 instantiations** (baseline: 62, 9.3x over) 8 - - Complex nested (3 defs): **971 instantiations** (baseline: 124, 7.8x over) 9 - 10 - **Target:** Reduce to ~200-250 instantiations for simple case 11 - 12 - ## Before This Optimization 13 - 14 - Previous optimizations had already been applied (likely "Make Prettify Lazy"): 15 - 16 - - Simple object: **275 instantiations** 17 - - Complex nested: **542 instantiations** 18 - 19 - ## The Problem 20 - 21 - The `ObjectResult` and `ParamsResult` types were computing `RequiredKeys<T>` and `NullableKeys<T>` as intermediate type parameters: 22 - 23 - ```typescript 24 - type ObjectResult< 25 - T extends ObjectProperties, 26 - R = RequiredKeys<T>, // ← Intermediate type parameter 27 - N = NullableKeys<T>, // ← Intermediate type parameter 28 - > = { 29 - type: "object"; 30 - properties: {...}; 31 - } & ([R] extends [never] ? {} : { required: UnionToTuple<R> }) 32 - & ([N] extends [never] ? {} : { nullable: UnionToTuple<N> }); 33 - ``` 34 - 35 - While the conditional checks prevented calling `UnionToTuple` when keys were `never`, TypeScript still had to instantiate the intermediate type parameters `R` and `N`, adding overhead. 36 - 37 - ## What We Tried 38 - 39 - ### Attempt 1: Remove Intermediate Type Parameters ✅ SUCCESS 40 - 41 - **Change:** Inline the `RequiredKeys<T>` and `NullableKeys<T>` calls directly into the conditional checks, removing the intermediate type parameter assignments. 42 - 43 - ```typescript 44 - type ObjectResult<T extends ObjectProperties> = { 45 - type: "object"; 46 - properties: {...}; 47 - } & ([RequiredKeys<T>] extends [never] 48 - ? {} 49 - : { required: UnionToTuple<RequiredKeys<T>> }) 50 - & ([NullableKeys<T>] extends [never] 51 - ? {} 52 - : { nullable: UnionToTuple<NullableKeys<T>> }); 53 - ``` 54 - 55 - **Results:** 56 - 57 - - Simple object: 275 → **244 instantiations** (31 saved, ~11% improvement) 58 - - Complex nested: 542 → **507 instantiations** (35 saved, ~6% improvement) 59 - 60 - **Why it worked:** Removing intermediate type parameter assignments reduced the number of type instantiations. TypeScript now evaluates the key extraction inline within conditionals, which is more efficient than creating separate type aliases. 61 - 62 - ### Attempt 2: Extract Helper Types ❌ FAILED 63 - 64 - **Change:** Created `AddRequiredField` and `AddNullableField` helper types to encapsulate the conditional logic: 65 - 66 - ```typescript 67 - type AddRequiredField<R> = [R] extends [never] 68 - ? {} 69 - : { required: UnionToTuple<R> }; 70 - 71 - type AddNullableField<N> = [N] extends [never] 72 - ? {} 73 - : { nullable: UnionToTuple<N> }; 74 - 75 - type ObjectResult<T extends ObjectProperties> = { 76 - type: "object"; 77 - properties: {...}; 78 - } & AddRequiredField<RequiredKeys<T>> 79 - & AddNullableField<NullableKeys<T>>; 80 - ``` 81 - 82 - **Results:** 83 - 84 - - Simple object: 244 → **263 instantiations** (19 worse, regressed) 85 - - Complex nested: 507 → **506 instantiations** (1 better, negligible) 86 - 87 - **Why it failed:** The helper types added additional type instantiation overhead that outweighed any benefits from code organization. Each helper type invocation created extra work for TypeScript's type checker. 88 - 89 - **Action taken:** Reverted to Attempt 1. 90 - 91 - ## Final Results 92 - 93 - **Benchmark after this optimization:** 94 - 95 - - Simple object: **244 instantiations** (down from 275, saved 31) 96 - - Complex nested: **507 instantiations** (down from 542, saved 35) 97 - 98 - **Total progress from original todo.md baseline:** 99 - 100 - - Simple object: **578 → 244** (334 saved, 57.8% improvement) ✅ **GOAL MET** (target: 200-250) 101 - - Complex nested: **971 → 507** (464 saved, 47.8% improvement) ⚠️ Still above 400 target 102 - 103 - ## Files Modified 104 - 105 - - `src/lib.ts`: 106 - - `ObjectResult<T>` type (lines 212-225) 107 - - `ParamsResult<T>` type (lines 237-245) 108 - 109 - ## Validation 110 - 111 - - ✅ All 172 tests passing (`pnpm test`) 112 - - ✅ Type checking passes (`pnpm tsc`) 113 - - ✅ No runtime behavior changes (types erase at runtime) 114 - - ✅ IDE autocomplete still works with clean type display 115 - 116 - ## Key Learnings 117 - 118 - 1. **Intermediate type parameters have a cost**: Even when they're just aliases, TypeScript must instantiate them. 119 - 120 - 2. **Inline conditionals can be more efficient**: Computing values inline within conditional types can reduce instantiation count compared to pre-computing and storing in type parameters. 121 - 122 - 3. **Helper types aren't always helpful**: While helper types improve code organization, they can add overhead. Always benchmark after introducing abstractions. 123 - 124 - 4. **Small changes add up**: A 31-instantiation reduction (11%) might seem modest, but combined with other optimizations, we achieved a 57.8% total improvement. 125 - 126 - --- 127 - 128 - ## Further Optimization Attempts (Post-Initial Success) 129 - 130 - ### Attempt 3: Reduce InferObject Intersections (4 → 2) ❌ FAILED 131 - 132 - **Change:** Attempted to reduce the number of intersected mapped types in `InferObject` from 4 to 2 by combining key categories: 133 - 134 - ```typescript 135 - type InferObject<...> = Prettify< 136 - T extends { properties: any } 137 - ? { 138 - // All REQUIRED keys (with and without nullable) 139 - -readonly [K in keyof Props as K extends Required & string ? K : never]-?: 140 - K extends NullableAndRequired 141 - ? InferType<Props[K]> | null 142 - : InferType<Props[K]>; 143 - } & { 144 - // All OPTIONAL keys (normal and nullable-only) 145 - -readonly [K in keyof Props as K extends Exclude<keyof Props & string, Required> ? K : never]?: 146 - K extends Nullable 147 - ? InferType<Props[K]> | null 148 - : InferType<Props[K]>; 149 - } 150 - : {} 151 - >; 152 - ``` 153 - 154 - **Results:** 155 - 156 - - Simple object: **244 instantiations** (unchanged) 157 - - Complex nested: **507 instantiations** (unchanged) 158 - 159 - **Why it failed:** 160 - 161 - - The number of intersections wasn't the bottleneck 162 - - TypeScript still evaluates the same number of conditional checks 163 - - Property ordering changed (required first, then optional), breaking snapshot tests 164 - - No performance benefit to justify the breaking change 165 - 166 - **Action taken:** Reverted. 167 - 168 - ### Attempt 4: Inline Type Parameters in InferObject ❌ FAILED 169 - 170 - **Change:** Removed all intermediate type parameters from `InferObject`, similar to what worked for `ObjectResult`: 171 - 172 - ```typescript 173 - type InferObject<T> = Prettify< 174 - T extends { properties: infer P } 175 - ? { 176 - -readonly [K in "properties" extends keyof T 177 - ? Exclude<keyof T["properties"], (GetRequired<T> & string) | (GetNullable<T> & string)> & string 178 - : never]?: InferType<P[K & keyof P]>; 179 - } & { 180 - -readonly [K in Exclude<GetRequired<T> & string, ...>]-?: InferType<P[K & keyof P]>; 181 - } & ... 182 - : {} 183 - >; 184 - ``` 185 - 186 - **Results:** 187 - 188 - - Simple object: **244 instantiations** (unchanged) 189 - - Complex nested: **507 instantiations** (unchanged) 190 - 191 - **Why it failed:** 192 - 193 - - Unlike `ObjectResult` (which is only evaluated at definition time), `InferObject` is called recursively during type inference 194 - - The intermediate type parameters are likely cached/memoized by TypeScript during recursive evaluation 195 - - Inlining forces re-computation of the same values multiple times in each mapped type key 196 - - Tests passed but no performance improvement 197 - 198 - **Action taken:** Reverted. 199 - 200 - ## Updated Key Learnings 201 - 202 - 5. **Context matters for optimizations**: What works in one context (removing intermediate params in `ObjectResult`) may not work in another (`InferObject`). The recursive nature of type inference behaves differently than one-time type construction. 203 - 204 - 6. **Intersection count isn't always the bottleneck**: Reducing from 4 to 2 intersections had zero impact, suggesting the real cost is elsewhere (likely `Prettify` at every nesting level or the recursive `InferType` calls). 205 - 206 - 7. **TypeScript may optimize intermediate parameters**: In recursive scenarios, intermediate type parameters might be cached, making inlining counterproductive. 207 - 208 - ### Attempt 5: Reorder InferType Dispatch Chain ❌ FAILED 209 - 210 - **Change:** Reordered the `InferType` conditional chain to prioritize the most commonly used types: 211 - 212 - **New order:** 213 - 214 - 1. object (most common container) 215 - 2. string (most common primitive) 216 - 3. ref (common for schema references) 217 - 4. array (common for collections) 218 - 5. union (common for polymorphic types) 219 - 6. integer, boolean (other common primitives) 220 - 7. record, params, null, token, unknown, bytes, cid-link, blob (less common) 221 - 222 - **Previous order:** 223 - 224 - 1. record, object, array, params, union, token, ref, unknown, null, boolean, integer, string, bytes, cid-link, blob 225 - 226 - ```typescript 227 - type InferType<T> = T extends { type: "object" } 228 - ? InferObject<T> 229 - : T extends { type: "string" } 230 - ? string 231 - : T extends { type: "ref" } 232 - ? InferRef<T> 233 - : T extends { type: "array" } 234 - ? InferArray<T> 235 - // ... rest of the chain 236 - ``` 237 - 238 - **Results:** 239 - 240 - - Simple object: **244 instantiations** (unchanged) 241 - - Complex nested: **507 instantiations** (unchanged) 242 - 243 - **Why it failed:** 244 - 245 - - TypeScript's type checker likely doesn't evaluate conditional chains linearly 246 - - The order of conditionals has no impact on performance 247 - - TypeScript may cache or optimize type instantiations internally regardless of order 248 - - Tests pass, proving functional correctness, but zero performance benefit 249 - 250 - **Action taken:** Kept the new order (it's more readable with common types first), but no performance gain. 251 - 252 - ## Next Potential Optimizations to Try 253 - 254 - 1. **Optimize `Prettify` itself** - Since it's called at every nesting level, making it more efficient could have cascading benefits 255 - 2. **Combine `GetRequired` and `GetNullable`** - Extract both in a single pass to reduce type instantiations 256 - 3. **Cache commonly used helper types** - Though previous attempts suggest this might not help 257 - 4. **Reduce Prettify calls** - Only call Prettify at the outermost level, not at every nesting
+43 -4
src/infer.ts
··· 94 94 : unknown 95 95 : unknown; 96 96 97 - type InferDefs<T extends Record<string, unknown>> = { 98 - -readonly [K in keyof T]: InferType<T[K]>; 99 - }; 97 + /** 98 + * Recursively replaces stub references in a type with their actual definitions. 99 + * Detects circular references and missing references, returning string literal error messages. 100 + */ 101 + type ReplaceRefsInType<T, Defs, Visited = never> = 102 + // Check if this is a ref stub type (has $type starting with #) 103 + T extends { $type: `#${infer DefName}` } 104 + ? DefName extends keyof Defs 105 + ? // Check for circular reference 106 + DefName extends Visited 107 + ? `[Circular reference detected: #${DefName}]` 108 + : // Recursively resolve the ref and preserve the $type marker 109 + Prettify< 110 + ReplaceRefsInType<Defs[DefName], Defs, Visited | DefName> & { 111 + $type: T["$type"]; 112 + } 113 + > 114 + : // Reference not found in definitions 115 + `[Reference not found: #${DefName}]` 116 + : // Handle arrays (but not Uint8Array or other typed arrays) 117 + T extends Uint8Array | Blob 118 + ? T 119 + : T extends readonly (infer Item)[] 120 + ? ReplaceRefsInType<Item, Defs, Visited>[] 121 + : // Handle plain objects (exclude built-in types and functions) 122 + T extends object 123 + ? T extends (...args: unknown[]) => unknown 124 + ? T 125 + : { [K in keyof T]: ReplaceRefsInType<T[K], Defs, Visited> } 126 + : // Primitives pass through unchanged 127 + T; 100 128 129 + /** 130 + * Infers the TypeScript type for a lexicon namespace, returning only the 'main' definition 131 + * with all local refs (#user, #post, etc.) resolved to their actual types. 132 + */ 101 133 export type Infer<T extends { id: string; defs: Record<string, unknown> }> = 102 - Prettify<InferDefs<T["defs"]>>; 134 + Prettify< 135 + "main" extends keyof T["defs"] 136 + ? { $type: T["id"] } & ReplaceRefsInType< 137 + InferType<T["defs"]["main"]>, 138 + { [K in keyof T["defs"]]: InferType<T["defs"][K]> } 139 + > 140 + : never 141 + >;
+39 -22
tests/infer.bench.ts
··· 9 9 }), 10 10 }); 11 11 return schema.infer; 12 - }).types([221, "instantiations"]); 12 + }).types([899, "instantiations"]); 13 13 14 14 bench("infer with complex nested structure", () => { 15 15 const schema = lx.namespace("test.complex", { 16 - post: lx.record({ 16 + user: lx.object({ 17 + handle: lx.string({ required: true }), 18 + displayName: lx.string(), 19 + }), 20 + reply: lx.object({ 21 + text: lx.string({ required: true }), 22 + author: lx.ref("#user", { required: true }), 23 + }), 24 + main: lx.record({ 17 25 key: "tid", 18 26 record: lx.object({ 19 - author: lx.ref("test.complex#user", { required: true }), 20 - replies: lx.array(lx.ref("test.complex#reply")), 27 + author: lx.ref("#user", { required: true }), 28 + replies: lx.array(lx.ref("#reply")), 21 29 content: lx.string({ required: true }), 22 30 createdAt: lx.string({ required: true, format: "datetime" }), 23 31 }), 24 32 }), 33 + }); 34 + return schema.infer; 35 + }).types([1040, "instantiations"]); 36 + 37 + bench("infer with circular reference", () => { 38 + const ns = lx.namespace("test", { 25 39 user: lx.object({ 26 - handle: lx.string({ required: true }), 27 - displayName: lx.string(), 40 + name: lx.string({ required: true }), 41 + posts: lx.array(lx.ref("#post")), 42 + }), 43 + post: lx.object({ 44 + title: lx.string({ required: true }), 45 + author: lx.ref("#user", { required: true }), 28 46 }), 29 - reply: lx.object({ 30 - text: lx.string({ required: true }), 31 - author: lx.ref("test.complex#user", { required: true }), 47 + main: lx.object({ 48 + users: lx.array(lx.ref("#user")), 32 49 }), 33 50 }); 34 - return schema.infer; 35 - }).types([454, "instantiations"]); 51 + return ns.infer; 52 + }).types([692, "instantiations"]); 36 53 37 54 bench("infer with app.bsky.feed.defs namespace", () => { 38 55 const schema = lx.namespace("app.bsky.feed.defs", { 39 - postView: lx.object({ 56 + viewerState: lx.object({ 57 + repost: lx.string({ format: "at-uri" }), 58 + like: lx.string({ format: "at-uri" }), 59 + bookmarked: lx.boolean(), 60 + threadMuted: lx.boolean(), 61 + replyDisabled: lx.boolean(), 62 + embeddingDisabled: lx.boolean(), 63 + pinned: lx.boolean(), 64 + }), 65 + main: lx.object({ 40 66 uri: lx.string({ required: true, format: "at-uri" }), 41 67 cid: lx.string({ required: true, format: "cid" }), 42 68 author: lx.ref("app.bsky.actor.defs#profileViewBasic", { ··· 60 86 labels: lx.array(lx.ref("com.atproto.label.defs#label")), 61 87 threadgate: lx.ref("#threadgateView"), 62 88 }), 63 - viewerState: lx.object({ 64 - repost: lx.string({ format: "at-uri" }), 65 - like: lx.string({ format: "at-uri" }), 66 - bookmarked: lx.boolean(), 67 - threadMuted: lx.boolean(), 68 - replyDisabled: lx.boolean(), 69 - embeddingDisabled: lx.boolean(), 70 - pinned: lx.boolean(), 71 - }), 72 89 requestLess: lx.token( 73 90 "Request that less content like the given feed item be shown in the feed", 74 91 ), ··· 99 116 interactionShare: lx.token("User shared the feed item"), 100 117 }); 101 118 return schema.infer; 102 - }).types([658, "instantiations"]); 119 + }).types([1285, "instantiations"]);
+323 -216
tests/infer.test.ts
··· 17 17 18 18 // Type snapshot - this captures how types appear on hover 19 19 attest(exampleLexicon.infer).type.toString.snap(`{ 20 - main: { 21 - tags?: string[] | undefined 22 - likes?: number | undefined 23 - createdAt: string 24 - text: string 25 - } 20 + $type: "com.example.post" 21 + tags?: string[] | undefined 22 + likes?: number | undefined 23 + createdAt: string 24 + text: string 26 25 }`); 27 26 }); 28 27 ··· 35 34 }); 36 35 37 36 attest(schema.infer).type.toString.snap(`{ 38 - main: { optional?: string | undefined; required: string } 37 + $type: "test" 38 + optional?: string | undefined 39 + required: string 39 40 }`); 40 41 }); 41 42 ··· 47 48 }); 48 49 49 50 attest(schema.infer).type.toString.snap( 50 - "{ main: { nullable: string | null } }", 51 + '{ $type: "test"; nullable: string | null }', 51 52 ); 52 53 }); 53 54 ··· 62 63 }), 63 64 }); 64 65 65 - attest(namespace.infer).type.toString.snap( 66 - "{ main: { simpleString?: string | undefined } }", 67 - ); 66 + attest(namespace.infer).type.toString.snap(`{ 67 + $type: "test.string" 68 + simpleString?: string | undefined 69 + }`); 68 70 }); 69 71 70 72 test("InferType handles integer primitive", () => { ··· 76 78 }); 77 79 78 80 attest(namespace.infer).type.toString.snap(`{ 79 - main: { 80 - count?: number | undefined 81 - age?: number | undefined 82 - } 81 + $type: "test.integer" 82 + count?: number | undefined 83 + age?: number | undefined 83 84 }`); 84 85 }); 85 86 ··· 92 93 }); 93 94 94 95 attest(namespace.infer).type.toString.snap(`{ 95 - main: { 96 - isActive?: boolean | undefined 97 - hasAccess: boolean 98 - } 96 + $type: "test.boolean" 97 + isActive?: boolean | undefined 98 + hasAccess: boolean 99 99 }`); 100 100 }); 101 101 ··· 106 106 }), 107 107 }); 108 108 109 - attest(namespace.infer).type.toString.snap( 110 - "{ main: { nullValue?: null | undefined } }", 111 - ); 109 + attest(namespace.infer).type.toString.snap(`{ 110 + $type: "test.null" 111 + nullValue?: null | undefined 112 + }`); 112 113 }); 113 114 114 115 test("InferType handles unknown primitive", () => { ··· 119 120 }); 120 121 121 122 attest(namespace.infer).type.toString.snap( 122 - "{ main: { metadata?: unknown } }", 123 + '{ $type: "test.unknown"; metadata?: unknown }', 123 124 ); 124 125 }); 125 126 ··· 131 132 }); 132 133 133 134 attest(namespace.infer).type.toString.snap(`{ 134 - main: { data?: Uint8Array<ArrayBufferLike> | undefined } 135 + $type: "test.bytes" 136 + data?: Uint8Array<ArrayBufferLike> | undefined 135 137 }`); 136 138 }); 137 139 ··· 143 145 }); 144 146 145 147 attest(namespace.infer).type.toString.snap( 146 - "{ main: { image?: Blob | undefined } }", 148 + '{ $type: "test.blob"; image?: Blob | undefined }', 147 149 ); 148 150 }); 149 151 ··· 158 160 }), 159 161 }); 160 162 161 - attest(namespace.infer).type.toString.snap( 162 - "{ main: { symbol?: string | undefined } }", 163 - ); 163 + attest(namespace.infer).type.toString.snap(`{ 164 + $type: "test.token" 165 + symbol?: string | undefined 166 + }`); 164 167 }); 165 168 166 169 // ============================================================================ ··· 174 177 }), 175 178 }); 176 179 177 - attest(namespace.infer).type.toString.snap( 178 - "{ main: { tags?: string[] | undefined } }", 179 - ); 180 + attest(namespace.infer).type.toString.snap(`{ 181 + $type: "test.array.string" 182 + tags?: string[] | undefined 183 + }`); 180 184 }); 181 185 182 186 test("InferArray handles integer arrays", () => { ··· 186 190 }), 187 191 }); 188 192 189 - attest(namespace.infer).type.toString.snap( 190 - "{ main: { scores?: number[] | undefined } }", 191 - ); 193 + attest(namespace.infer).type.toString.snap(`{ 194 + $type: "test.array.integer" 195 + scores?: number[] | undefined 196 + }`); 192 197 }); 193 198 194 199 test("InferArray handles boolean arrays", () => { ··· 198 203 }), 199 204 }); 200 205 201 - attest(namespace.infer).type.toString.snap( 202 - "{ main: { flags?: boolean[] | undefined } }", 203 - ); 206 + attest(namespace.infer).type.toString.snap(`{ 207 + $type: "test.array.boolean" 208 + flags?: boolean[] | undefined 209 + }`); 204 210 }); 205 211 206 212 test("InferArray handles unknown arrays", () => { ··· 210 216 }), 211 217 }); 212 218 213 - attest(namespace.infer).type.toString.snap( 214 - "{ main: { items?: unknown[] | undefined } }", 215 - ); 219 + attest(namespace.infer).type.toString.snap(`{ 220 + $type: "test.array.unknown" 221 + items?: unknown[] | undefined 222 + }`); 216 223 }); 217 224 218 225 // ============================================================================ ··· 230 237 }); 231 238 232 239 attest(namespace.infer).type.toString.snap(`{ 233 - main: { 234 - age?: number | undefined 235 - email?: string | undefined 236 - id: string 237 - name: string 238 - } 240 + $type: "test.mixed" 241 + age?: number | undefined 242 + email?: string | undefined 243 + id: string 244 + name: string 239 245 }`); 240 246 }); 241 247 ··· 249 255 }); 250 256 251 257 attest(namespace.infer).type.toString.snap(`{ 252 - main: { 253 - field1?: string | undefined 254 - field2?: number | undefined 255 - field3?: boolean | undefined 256 - } 258 + $type: "test.allOptional" 259 + field1?: string | undefined 260 + field2?: number | undefined 261 + field3?: boolean | undefined 257 262 }`); 258 263 }); 259 264 ··· 267 272 }); 268 273 269 274 attest(namespace.infer).type.toString.snap(`{ 270 - main: { field1: string; field2: number; field3: boolean } 275 + $type: "test.allRequired" 276 + field1: string 277 + field2: number 278 + field3: boolean 271 279 }`); 272 280 }); 273 281 ··· 283 291 }); 284 292 285 293 attest(namespace.infer).type.toString.snap(`{ 286 - main: { description?: string | null | undefined } 294 + $type: "test.nullableOptional" 295 + description?: string | null | undefined 287 296 }`); 288 297 }); 289 298 ··· 297 306 }); 298 307 299 308 attest(namespace.infer).type.toString.snap(`{ 300 - main: { 301 - field1?: string | null | undefined 302 - field2?: number | null | undefined 303 - field3?: boolean | null | undefined 304 - } 309 + $type: "test.multipleNullable" 310 + field1?: string | null | undefined 311 + field2?: number | null | undefined 312 + field3?: boolean | null | undefined 305 313 }`); 306 314 }); 307 315 ··· 312 320 }), 313 321 }); 314 322 315 - attest(namespace.infer).type.toString.snap( 316 - "{ main: { value: string | null } }", 317 - ); 323 + attest(namespace.infer).type.toString.snap(`{ 324 + $type: "test.nullableRequired" 325 + value: string | null 326 + }`); 318 327 }); 319 328 320 329 test("InferObject handles mixed nullable, required, and optional", () => { ··· 328 337 }); 329 338 330 339 attest(namespace.infer).type.toString.snap(`{ 331 - main: { 332 - optional?: string | undefined 333 - required: string 334 - optionalNullable?: string | null | undefined 335 - requiredNullable: string | null 336 - } 340 + $type: "test.mixedNullable" 341 + optional?: string | undefined 342 + required: string 343 + optionalNullable?: string | null | undefined 344 + requiredNullable: string | null 337 345 }`); 338 346 }); 339 347 ··· 349 357 }); 350 358 351 359 attest(namespace.infer).type.toString.snap(`{ 352 - main: { 353 - post?: 354 - | { 355 - [key: string]: unknown 356 - $type: "com.example.post" 357 - } 358 - | undefined 359 - } 360 + $type: "test.ref" 361 + post?: 362 + | { [x: string]: unknown; $type: "com.example.post" } 363 + | undefined 360 364 }`); 361 365 }); 362 366 ··· 368 372 }); 369 373 370 374 attest(namespace.infer).type.toString.snap(`{ 371 - main: { 372 - author?: 373 - | { 374 - [key: string]: unknown 375 - $type: "com.example.user" 376 - } 377 - | undefined 378 - } 375 + $type: "test.refRequired" 376 + author?: 377 + | { [x: string]: unknown; $type: "com.example.user" } 378 + | undefined 379 379 }`); 380 380 }); 381 381 ··· 387 387 }); 388 388 389 389 attest(namespace.infer).type.toString.snap(`{ 390 - main: { 391 - parent?: 392 - | { 393 - [key: string]: unknown 394 - $type: "com.example.node" 395 - } 396 - | undefined 397 - } 390 + $type: "test.refNullable" 391 + parent?: 392 + | { [x: string]: unknown; $type: "com.example.node" } 393 + | undefined 398 394 }`); 399 395 }); 400 396 ··· 410 406 }); 411 407 412 408 attest(namespace.infer).type.toString.snap(`{ 413 - main: { 414 - content?: 415 - | { 416 - [key: string]: unknown 417 - $type: "com.example.text" 418 - } 419 - | { 420 - [key: string]: unknown 421 - $type: "com.example.image" 422 - } 423 - | undefined 424 - } 409 + $type: "test.union" 410 + content?: 411 + | { [x: string]: unknown; $type: "com.example.text" } 412 + | { [x: string]: unknown; $type: "com.example.image" } 413 + | undefined 425 414 }`); 426 415 }); 427 416 ··· 435 424 }); 436 425 437 426 attest(namespace.infer).type.toString.snap(`{ 438 - main: { 439 - media: 440 - | { 441 - [key: string]: unknown 442 - $type: "com.example.video" 443 - } 444 - | { 445 - [key: string]: unknown 446 - $type: "com.example.audio" 447 - } 448 - } 427 + $type: "test.unionRequired" 428 + media: 429 + | { [x: string]: unknown; $type: "com.example.video" } 430 + | { [x: string]: unknown; $type: "com.example.audio" } 449 431 }`); 450 432 }); 451 433 ··· 462 444 }); 463 445 464 446 attest(namespace.infer).type.toString.snap(`{ 465 - main: { 466 - attachment?: 467 - | { 468 - [key: string]: unknown 469 - $type: "com.example.image" 470 - } 471 - | { 472 - [key: string]: unknown 473 - $type: "com.example.video" 474 - } 475 - | { 476 - [key: string]: unknown 477 - $type: "com.example.audio" 478 - } 479 - | { 480 - [key: string]: unknown 481 - $type: "com.example.document" 482 - } 483 - | undefined 484 - } 447 + $type: "test.unionMultiple" 448 + attachment?: 449 + | { [x: string]: unknown; $type: "com.example.image" } 450 + | { [x: string]: unknown; $type: "com.example.video" } 451 + | { [x: string]: unknown; $type: "com.example.audio" } 452 + | { 453 + [x: string]: unknown 454 + $type: "com.example.document" 455 + } 456 + | undefined 485 457 }`); 486 458 }); 487 459 ··· 498 470 }); 499 471 500 472 attest(namespace.infer).type.toString.snap(`{ 501 - main: { 502 - limit?: number | undefined 503 - offset?: number | undefined 504 - } 473 + $type: "test.params" 474 + limit?: number | undefined 475 + offset?: number | undefined 505 476 }`); 506 477 }); 507 478 ··· 514 485 }); 515 486 516 487 attest(namespace.infer).type.toString.snap(`{ 517 - main: { limit?: number | undefined; query: string } 488 + $type: "test.paramsRequired" 489 + limit?: number | undefined 490 + query: string 518 491 }`); 519 492 }); 520 493 ··· 535 508 }); 536 509 537 510 attest(namespace.infer).type.toString.snap(`{ 538 - main: { 539 - published?: boolean | undefined 540 - content: string 541 - title: string 542 - } 511 + $type: "test.record" 512 + published?: boolean | undefined 513 + content: string 514 + title: string 543 515 }`); 544 516 }); 545 517 ··· 558 530 }); 559 531 560 532 attest(namespace.infer).type.toString.snap(`{ 561 - main: { 562 - user?: { name: string; email: string } | undefined 563 - } 533 + $type: "test.nested" 534 + user?: { name: string; email: string } | undefined 564 535 }`); 565 536 }); 566 537 ··· 578 549 }); 579 550 580 551 attest(namespace.infer).type.toString.snap(`{ 581 - main: { 582 - data?: 583 - | { 584 - user?: 585 - | { profile?: { name: string } | undefined } 586 - | undefined 587 - } 588 - | undefined 589 - } 552 + $type: "test.deepNested" 553 + data?: 554 + | { 555 + user?: 556 + | { profile?: { name: string } | undefined } 557 + | undefined 558 + } 559 + | undefined 590 560 }`); 591 561 }); 592 562 ··· 607 577 }); 608 578 609 579 attest(namespace.infer).type.toString.snap(`{ 610 - main: { 611 - users?: { id: string; name: string }[] | undefined 612 - } 580 + $type: "test.arrayOfObjects" 581 + users?: { id: string; name: string }[] | undefined 613 582 }`); 614 583 }); 615 584 ··· 622 591 main: schema, 623 592 }); 624 593 625 - attest(namespace.infer).type.toString.snap( 626 - "{ main: { matrix?: number[][] | undefined } }", 627 - ); 594 + attest(namespace.infer).type.toString.snap(`{ 595 + $type: "test.nestedArrays" 596 + matrix?: number[][] | undefined 597 + }`); 628 598 }); 629 599 630 600 test("InferArray handles arrays of refs", () => { ··· 635 605 }); 636 606 637 607 attest(namespace.infer).type.toString.snap(`{ 638 - main: { 639 - followers?: 640 - | { 641 - [key: string]: unknown 642 - $type: "com.example.user" 643 - }[] 644 - | undefined 645 - } 608 + $type: "test.arrayOfRefs" 609 + followers?: 610 + | { [x: string]: unknown; $type: "com.example.user" }[] 611 + | undefined 646 612 }`); 647 613 }); 648 614 ··· 670 636 }); 671 637 672 638 attest(namespace.infer).type.toString.snap(`{ 673 - main: { 674 - tags?: string[] | undefined 675 - content?: 676 - | { 677 - [key: string]: unknown 678 - $type: "com.example.text" 679 - } 680 - | { 681 - [key: string]: unknown 682 - $type: "com.example.image" 683 - } 684 - | undefined 685 - author?: 686 - | { 687 - avatar?: string | undefined 688 - did: string 689 - handle: string 690 - } 691 - | undefined 692 - metadata?: 693 - | { 694 - likes?: number | undefined 695 - views?: number | undefined 696 - shares?: number | undefined 697 - } 698 - | undefined 699 - id: string 700 - } 639 + $type: "test.complex" 640 + tags?: string[] | undefined 641 + content?: 642 + | { [x: string]: unknown; $type: "com.example.text" } 643 + | { [x: string]: unknown; $type: "com.example.image" } 644 + | undefined 645 + author?: 646 + | { 647 + avatar?: string | undefined 648 + did: string 649 + handle: string 650 + } 651 + | undefined 652 + metadata?: 653 + | { 654 + likes?: number | undefined 655 + views?: number | undefined 656 + shares?: number | undefined 657 + } 658 + | undefined 659 + id: string 701 660 }`); 702 661 }); 703 662 ··· 721 680 }), 722 681 }); 723 682 724 - attest(namespace.infer).type.toString.snap(`{ 725 - user: { name: string; email: string } 726 - post: { content: string; title: string } 727 - comment: { 728 - author?: 729 - | { 730 - [key: string]: unknown 731 - $type: "com.example.user" 732 - } 733 - | undefined 734 - text: string 735 - } 736 - }`); 683 + attest(namespace.infer).type.toString.snap("never"); 737 684 }); 738 685 739 686 test("InferNS handles namespace with record and object defs", () => { ··· 752 699 }); 753 700 754 701 attest(namespace.infer).type.toString.snap(`{ 755 - main: { title: string; body: string } 756 - metadata: { 757 - tags?: string[] | undefined 758 - category?: string | undefined 759 - } 702 + $type: "com.example.blog" 703 + title: string 704 + body: string 705 + }`); 706 + }); 707 + 708 + // ============================================================================ 709 + // LOCAL REF RESOLUTION TESTS 710 + // ============================================================================ 711 + 712 + test("Local ref resolution: resolves refs to actual types", () => { 713 + const ns = lx.namespace("test", { 714 + user: lx.object({ 715 + name: lx.string({ required: true }), 716 + email: lx.string({ required: true }), 717 + }), 718 + main: lx.object({ 719 + author: lx.ref("#user", { required: true }), 720 + content: lx.string({ required: true }), 721 + }), 722 + }); 723 + 724 + attest(ns.infer).type.toString.snap(`{ 725 + $type: "test" 726 + author?: 727 + | { name: string; email: string; $type: "#user" } 728 + | undefined 729 + content: string 730 + }`); 731 + }); 732 + 733 + test("Local ref resolution: refs in arrays", () => { 734 + const ns = lx.namespace("test", { 735 + user: lx.object({ 736 + name: lx.string({ required: true }), 737 + }), 738 + main: lx.object({ 739 + users: lx.array(lx.ref("#user")), 740 + }), 741 + }); 742 + 743 + attest(ns.infer).type.toString.snap(`{ 744 + $type: "test" 745 + users?: { name: string; $type: "#user" }[] | undefined 746 + }`); 747 + }); 748 + 749 + test("Local ref resolution: refs in unions", () => { 750 + const ns = lx.namespace("test", { 751 + text: lx.object({ content: lx.string({ required: true }) }), 752 + image: lx.object({ url: lx.string({ required: true }) }), 753 + main: lx.object({ 754 + embed: lx.union(["#text", "#image"]), 755 + }), 756 + }); 757 + 758 + attest(ns.infer).type.toString.snap(`{ 759 + $type: "test" 760 + embed?: 761 + | { content: string; $type: "#text" } 762 + | { url: string; $type: "#image" } 763 + | undefined 764 + }`); 765 + }); 766 + 767 + test("Local ref resolution: nested refs", () => { 768 + const ns = lx.namespace("test", { 769 + profile: lx.object({ 770 + bio: lx.string({ required: true }), 771 + }), 772 + user: lx.object({ 773 + name: lx.string({ required: true }), 774 + profile: lx.ref("#profile", { required: true }), 775 + }), 776 + main: lx.object({ 777 + author: lx.ref("#user", { required: true }), 778 + }), 779 + }); 780 + 781 + attest(ns.infer).type.toString.snap(`{ 782 + $type: "test" 783 + author?: 784 + | { 785 + profile?: 786 + | { bio: string; $type: "#profile" } 787 + | undefined 788 + name: string 789 + $type: "#user" 790 + } 791 + | undefined 792 + }`); 793 + }); 794 + 795 + // ============================================================================ 796 + // EDGE CASE TESTS 797 + // ============================================================================ 798 + 799 + test("Edge case: circular reference detection", () => { 800 + const ns = lx.namespace("test", { 801 + main: lx.object({ 802 + value: lx.string({ required: true }), 803 + parent: lx.ref("#main"), 804 + }), 805 + }); 806 + 807 + attest(ns.infer).type.toString.snap(`{ 808 + $type: "test" 809 + parent?: 810 + | { 811 + parent?: 812 + | "[Circular reference detected: #main]" 813 + | undefined 814 + value: string 815 + $type: "#main" 816 + } 817 + | undefined 818 + value: string 819 + }`); 820 + }); 821 + 822 + test("Edge case: circular reference between multiple types", () => { 823 + const ns = lx.namespace("test", { 824 + user: lx.object({ 825 + name: lx.string({ required: true }), 826 + posts: lx.array(lx.ref("#post")), 827 + }), 828 + post: lx.object({ 829 + title: lx.string({ required: true }), 830 + author: lx.ref("#user", { required: true }), 831 + }), 832 + main: lx.object({ 833 + users: lx.array(lx.ref("#user")), 834 + }), 835 + }); 836 + 837 + attest(ns.infer).type.toString.snap(`{ 838 + $type: "test" 839 + users?: 840 + | { 841 + posts?: 842 + | { 843 + author?: 844 + | "[Circular reference detected: #user]" 845 + | undefined 846 + title: string 847 + $type: "#post" 848 + }[] 849 + | undefined 850 + name: string 851 + $type: "#user" 852 + }[] 853 + | undefined 854 + }`); 855 + }); 856 + 857 + test("Edge case: missing reference detection", () => { 858 + const ns = lx.namespace("test", { 859 + main: lx.object({ 860 + author: lx.ref("#user", { required: true }), 861 + }), 862 + }); 863 + 864 + attest(ns.infer).type.toString.snap(`{ 865 + $type: "test" 866 + author?: "[Reference not found: #user]" | undefined 760 867 }`); 761 868 });