An experimental TypeSpec syntax for Lexicon

add @default decorator; uninline scalars by default

authored by danabra.mov and committed by Tangled 0c277f30 6c9b730d

Changed files
+529 -9
packages
emitter
lib
src
test
integration
lexicon-examples
input
community
lexicon
calendar
output
community
lexicon
+164 -1
DOCS.md
··· 312 312 313 313 Note that `Caption` won't exist as a separate def—the abstraction is erased in the output. 314 314 315 + ### Scalars 316 + 317 + TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models): 318 + 319 + ```typescript 320 + import "@typelex/emitter"; 321 + 322 + namespace com.example { 323 + model Main { 324 + handle?: Handle; 325 + bio?: Bio; 326 + } 327 + 328 + @maxLength(50) 329 + scalar Handle extends string; 330 + 331 + @maxLength(256) 332 + @maxGraphemes(128) 333 + scalar Bio extends string; 334 + } 335 + ``` 336 + 337 + This creates three defs: `main`, `handle`, and `bio`: 338 + 339 + ```json 340 + { 341 + "id": "com.example", 342 + "defs": { 343 + "main": { 344 + "type": "object", 345 + "properties": { 346 + "handle": { "type": "ref", "ref": "#handle" }, 347 + "bio": { "type": "ref", "ref": "#bio" } 348 + } 349 + }, 350 + "handle": { 351 + "type": "string", 352 + "maxLength": 50 353 + }, 354 + "bio": { 355 + "type": "string", 356 + "maxLength": 256, 357 + "maxGraphemes": 128 358 + } 359 + } 360 + } 361 + ``` 362 + 363 + Use `@inline` to expand a scalar inline instead: 364 + 365 + ```typescript 366 + import "@typelex/emitter"; 367 + 368 + namespace com.example { 369 + model Main { 370 + handle?: Handle; 371 + } 372 + 373 + @inline 374 + @maxLength(50) 375 + scalar Handle extends string; 376 + } 377 + ``` 378 + 379 + Now `Handle` is expanded inline (no separate def): 380 + 381 + ```json 382 + // ... 383 + "properties": { 384 + "handle": { "type": "string", "maxLength": 50 } 385 + } 386 + // ... 387 + ``` 388 + 315 389 ## Top-Level Lexicon Types 316 390 317 391 TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes. ··· 905 979 906 980 ## Defaults and Constants 907 981 908 - ### Defaults 982 + ### Property Defaults 983 + 984 + You can set default values on properties: 909 985 910 986 ```typescript 911 987 import "@typelex/emitter"; ··· 919 995 ``` 920 996 921 997 Maps to: `{"default": 1}`, `{"default": "en"}` 998 + 999 + ### Type Defaults 1000 + 1001 + You can also set defaults on scalar and union types using the `@default` decorator: 1002 + 1003 + ```typescript 1004 + import "@typelex/emitter"; 1005 + 1006 + namespace com.example { 1007 + model Main { 1008 + mode?: Mode; 1009 + priority?: Priority; 1010 + } 1011 + 1012 + @default("standard") 1013 + scalar Mode extends string; 1014 + 1015 + @default(1) 1016 + @closed 1017 + @inline 1018 + union Priority { 1, 2, 3 } 1019 + } 1020 + ``` 1021 + 1022 + This creates a default on the type definition itself: 1023 + 1024 + ```json 1025 + { 1026 + "defs": { 1027 + "mode": { 1028 + "type": "string", 1029 + "default": "standard" 1030 + } 1031 + } 1032 + } 1033 + ``` 1034 + 1035 + For unions with token references, pass the model directly: 1036 + 1037 + ```typescript 1038 + import "@typelex/emitter"; 1039 + 1040 + namespace com.example { 1041 + model Main { 1042 + eventType?: EventType; 1043 + } 1044 + 1045 + @default(InPerson) 1046 + union EventType { Hybrid, InPerson, Virtual, string } 1047 + 1048 + @token model Hybrid {} 1049 + @token model InPerson {} 1050 + @token model Virtual {} 1051 + } 1052 + ``` 1053 + 1054 + This resolves to the fully-qualified token NSID: 1055 + 1056 + ```json 1057 + { 1058 + "eventType": { 1059 + "type": "string", 1060 + "knownValues": [ 1061 + "com.example#hybrid", 1062 + "com.example#inPerson", 1063 + "com.example#virtual" 1064 + ], 1065 + "default": "com.example#inPerson" 1066 + } 1067 + } 1068 + ``` 1069 + 1070 + **Important:** When a scalar or union creates a standalone def (not `@inline`), property-level defaults must match the type's `@default`. Otherwise you'll get an error: 1071 + 1072 + ```typescript 1073 + @default("standard") 1074 + scalar Mode extends string; 1075 + 1076 + model Main { 1077 + mode?: Mode = "custom"; // ERROR: Conflicting defaults! 1078 + } 1079 + ``` 1080 + 1081 + Solutions: 1082 + 1. Make the defaults match: `mode?: Mode = "standard"` 1083 + 2. Mark the type `@inline`: Allows property-level defaults 1084 + 3. Remove the property default: Uses the type's default 922 1085 923 1086 ### Constants 924 1087
+40
packages/emitter/lib/decorators.tsp
··· 163 163 extern dec errors(target: unknown, ...errors: unknown[]); 164 164 165 165 /** 166 + * Forces a model, scalar, or union to be inlined instead of creating a standalone def. 167 + * By default, named types create separate definitions with references. 168 + * Use @inline to expand the type inline at each usage site. 169 + * 170 + * @example Inline model 171 + * ```typespec 172 + * @inline 173 + * model Caption { 174 + * text?: string; 175 + * } 176 + * 177 + * model Main { 178 + * captions?: Caption[]; // Expands inline, no separate "caption" def 179 + * } 180 + * ``` 181 + * 182 + * @example Inline scalar 183 + * ```typespec 184 + * @inline 185 + * @maxLength(50) 186 + * scalar Handle extends string; 187 + * 188 + * model Main { 189 + * handle?: Handle; // Expands to { type: "string", maxLength: 50 } 190 + * } 191 + * ``` 192 + * 193 + * @example Inline union 194 + * ```typespec 195 + * @inline 196 + * union Status { "active", "inactive", string } 197 + * 198 + * model Main { 199 + * status?: Status; // Expands inline with knownValues 200 + * } 201 + * ``` 202 + */ 203 + extern dec inline(target: unknown); 204 + 205 + /** 166 206 * Specifies a default value for a scalar or union definition. 167 207 * Only valid on standalone scalar or union defs (not @inline). 168 208 * The value must match the underlying type (string, integer, or boolean).
+39 -6
packages/emitter/src/emitter.ts
··· 641 641 isClosed(this.program, unionType) 642 642 ) { 643 643 const propDesc = prop ? getDoc(this.program, prop) : undefined; 644 - const defaultValue = prop?.defaultValue 645 - ? serializeValueAsJson(this.program, prop.defaultValue, prop) 646 - : undefined; 644 + 645 + // Check for default value: property default takes precedence, then union's @default 646 + let defaultValue: string | number | boolean | undefined; 647 + if (prop?.defaultValue !== undefined) { 648 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; 649 + } else { 650 + // If no property default, check union's @default decorator 651 + const rawUnionDefault = getDefault(this.program, unionType); 652 + const unionDefault = this.processDefaultValue(rawUnionDefault); 653 + if (unionDefault !== undefined && typeof unionDefault === 'number') { 654 + defaultValue = unionDefault; 655 + } 656 + } 657 + 647 658 return { 648 659 type: "integer", 649 660 enum: variants.numericLiterals, ··· 666 677 ) { 667 678 const isClosedUnion = isClosed(this.program, unionType); 668 679 const propDesc = prop ? getDoc(this.program, prop) : undefined; 669 - const defaultValue = prop?.defaultValue 670 - ? serializeValueAsJson(this.program, prop.defaultValue, prop) 671 - : undefined; 680 + 681 + // Check for default value: property default takes precedence, then union's @default 682 + let defaultValue: string | number | boolean | undefined; 683 + if (prop?.defaultValue !== undefined) { 684 + defaultValue = serializeValueAsJson(this.program, prop.defaultValue, prop) as any; 685 + } else { 686 + // If no property default, check union's @default decorator 687 + const rawUnionDefault = getDefault(this.program, unionType); 688 + const unionDefault = this.processDefaultValue(rawUnionDefault); 689 + 690 + if (unionDefault !== undefined) { 691 + // Check if it's a Type (model reference for tokens) 692 + if (typeof unionDefault === 'object' && 'kind' in unionDefault && unionDefault.kind === 'Model') { 693 + // Resolve the model reference to its NSID 694 + const tokenModel = unionDefault as Model; 695 + const tokenRef = this.getModelReference(tokenModel, true); // fullyQualified=true 696 + if (tokenRef) { 697 + defaultValue = tokenRef; 698 + } 699 + } else if (typeof unionDefault === 'string') { 700 + defaultValue = unionDefault; 701 + } 702 + } 703 + } 704 + 672 705 const maxLength = getMaxLength(this.program, unionType); 673 706 const minLength = getMinLength(this.program, unionType); 674 707 const maxGraphemes = getMaxGraphemes(this.program, unionType);
+125
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace community.lexicon.calendar.event { 4 + /** A calendar event. */ 5 + @rec("tid") 6 + model Main { 7 + /** The name of the event. */ 8 + @required 9 + name: string; 10 + 11 + /** The description of the event. */ 12 + description?: string; 13 + 14 + /** Client-declared timestamp when the event was created. */ 15 + @required 16 + createdAt: datetime; 17 + 18 + /** Client-declared timestamp when the event starts. */ 19 + startsAt?: datetime; 20 + 21 + /** Client-declared timestamp when the event ends. */ 22 + endsAt?: datetime; 23 + 24 + /** The attendance mode of the event. */ 25 + mode?: Mode; 26 + 27 + /** The status of the event. */ 28 + status?: Status; 29 + 30 + /** The locations where the event takes place. */ 31 + locations?: ( 32 + | Uri 33 + | community.lexicon.location.address.Main 34 + | community.lexicon.location.fsq.Main 35 + | community.lexicon.location.geo.Main 36 + | community.lexicon.location.hthree.Main 37 + )[]; 38 + 39 + /** URIs associated with the event. */ 40 + uris?: Uri[]; 41 + } 42 + 43 + /** The mode of the event. */ 44 + @default(Inperson) 45 + union Mode { 46 + Hybrid, 47 + Inperson, 48 + Virtual, 49 + string, 50 + } 51 + 52 + /** A virtual event that takes place online. */ 53 + @token 54 + model Virtual {} 55 + 56 + /** An in-person event that takes place offline. */ 57 + @token 58 + model Inperson {} 59 + 60 + /** A hybrid event that takes place both online and offline. */ 61 + @token 62 + model Hybrid {} 63 + 64 + /** The status of the event. */ 65 + @default(Scheduled) 66 + union Status { 67 + Cancelled, 68 + Planned, 69 + Postponed, 70 + Rescheduled, 71 + Scheduled, 72 + string, 73 + } 74 + 75 + /** The event has been created, but not finalized. */ 76 + @token 77 + model Planned {} 78 + 79 + /** The event has been created and scheduled. */ 80 + @token 81 + model Scheduled {} 82 + 83 + /** The event has been rescheduled. */ 84 + @token 85 + model Rescheduled {} 86 + 87 + /** The event has been cancelled. */ 88 + @token 89 + model Cancelled {} 90 + 91 + /** The event has been postponed and a new start date has not been set. */ 92 + @token 93 + model Postponed {} 94 + 95 + /** A URI associated with the event. */ 96 + model Uri { 97 + @required 98 + uri: uri; 99 + 100 + /** The display name of the URI. */ 101 + name?: string; 102 + } 103 + } 104 + 105 + // --- Externals --- 106 + 107 + @external 108 + namespace community.lexicon.location.address { 109 + model Main {} 110 + } 111 + 112 + @external 113 + namespace community.lexicon.location.fsq { 114 + model Main {} 115 + } 116 + 117 + @external 118 + namespace community.lexicon.location.geo { 119 + model Main {} 120 + } 121 + 122 + @external 123 + namespace community.lexicon.location.hthree { 124 + model Main {} 125 + }
+41
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace community.lexicon.calendar.rsvp { 4 + /** An RSVP for an event. */ 5 + @rec("tid") 6 + model Main { 7 + @required 8 + subject: `com`.atproto.repo.strongRef.Main; 9 + 10 + @required 11 + status: Status; 12 + } 13 + 14 + @inline 15 + @default(Going) 16 + union Status { 17 + Interested, 18 + Going, 19 + Notgoing, 20 + string, 21 + } 22 + 23 + /** Interested in the event */ 24 + @token 25 + model Interested {} 26 + 27 + /** Going to the event */ 28 + @token 29 + model Going {} 30 + 31 + /** Not going to the event */ 32 + @token 33 + model Notgoing {} 34 + } 35 + 36 + // --- Externals --- 37 + 38 + @external 39 + namespace `com`.atproto.repo.strongRef { 40 + model Main {} 41 + }
+119 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json
··· 2 2 "lexicon": 1, 3 3 "id": "community.lexicon.calendar.event", 4 4 "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A calendar event.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "name", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "name": { 17 + "type": "string", 18 + "description": "The name of the event." 19 + }, 20 + "description": { 21 + "type": "string", 22 + "description": "The description of the event." 23 + }, 24 + "createdAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Client-declared timestamp when the event was created." 28 + }, 29 + "startsAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "Client-declared timestamp when the event starts." 33 + }, 34 + "endsAt": { 35 + "type": "string", 36 + "format": "datetime", 37 + "description": "Client-declared timestamp when the event ends." 38 + }, 39 + "mode": { 40 + "type": "ref", 41 + "ref": "#mode", 42 + "description": "The attendance mode of the event." 43 + }, 44 + "status": { 45 + "type": "ref", 46 + "ref": "#status", 47 + "description": "The status of the event." 48 + }, 49 + "locations": { 50 + "type": "array", 51 + "description": "The locations where the event takes place.", 52 + "items": { 53 + "type": "union", 54 + "refs": [ 55 + "#uri", 56 + "community.lexicon.location.address", 57 + "community.lexicon.location.fsq", 58 + "community.lexicon.location.geo", 59 + "community.lexicon.location.hthree" 60 + ] 61 + } 62 + }, 63 + "uris": { 64 + "type": "array", 65 + "description": "URIs associated with the event.", 66 + "items": { 67 + "type": "ref", 68 + "ref": "#uri" 69 + } 70 + } 71 + } 72 + } 73 + }, 5 74 "mode": { 6 75 "type": "string", 7 76 "description": "The mode of the event.", ··· 23 92 "hybrid": { 24 93 "type": "token", 25 94 "description": "A hybrid event that takes place both online and offline." 95 + }, 96 + "status": { 97 + "type": "string", 98 + "description": "The status of the event.", 99 + "default": "community.lexicon.calendar.event#scheduled", 100 + "knownValues": [ 101 + "community.lexicon.calendar.event#cancelled", 102 + "community.lexicon.calendar.event#planned", 103 + "community.lexicon.calendar.event#postponed", 104 + "community.lexicon.calendar.event#rescheduled", 105 + "community.lexicon.calendar.event#scheduled" 106 + ] 107 + }, 108 + "planned": { 109 + "type": "token", 110 + "description": "The event has been created, but not finalized." 111 + }, 112 + "scheduled": { 113 + "type": "token", 114 + "description": "The event has been created and scheduled." 115 + }, 116 + "rescheduled": { 117 + "type": "token", 118 + "description": "The event has been rescheduled." 119 + }, 120 + "cancelled": { 121 + "type": "token", 122 + "description": "The event has been cancelled." 123 + }, 124 + "postponed": { 125 + "type": "token", 126 + "description": "The event has been postponed and a new start date has not been set." 127 + }, 128 + "uri": { 129 + "type": "object", 130 + "description": "A URI associated with the event.", 131 + "required": [ 132 + "uri" 133 + ], 134 + "properties": { 135 + "uri": { 136 + "type": "string", 137 + "format": "uri" 138 + }, 139 + "name": { 140 + "type": "string", 141 + "description": "The display name of the URI." 142 + } 143 + } 26 144 } 27 145 } 28 - } 146 + }
+1 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json
··· 42 42 "description": "Not going to the event" 43 43 } 44 44 } 45 - } 45 + }