An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

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

feat: add nullable support for object properties

Map TypeSpec `T | null` unions to Lexicon nullable field:
- `email?: string | null` → nullable: ["email"]
- `age: int32 | null` → nullable: ["age"]

Implementation:
- Detect Union types containing null variant
- Extract non-null type and process normally
- Add property name to nullable array on object
- Emit nullable field with array of nullable property names

Example:
```typespec
model User {
name: string;
email?: string | null; // Optional and can be null
age: int32 | null; // Required but can be null
}
```

Emits:
```json
{
"type": "object",
"required": ["name", "age"],
"nullable": ["email", "age"],
"properties": {
"name": { "type": "string" },
"email": { "type": "string" },
"age": { "type": "integer" }
}
}
```

Tests: All 23 existing tests remain green (no fixtures changed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+28 -224
+27 -1
packages/emitter/src/emitter.ts
··· 300 300 ? getDoc(this.program, model) 301 301 : undefined; 302 302 const required: string[] = []; 303 + const nullable: string[] = []; 303 304 const properties: any = {}; 304 305 305 306 for (const [name, prop] of model.properties) { ··· 307 308 required.push(name); 308 309 } 309 310 310 - const propDef = this.typeToLexiconDefinition(prop.type, prop); 311 + // Check if property type is a union with null 312 + let typeToProcess = prop.type; 313 + if (prop.type.kind === "Union") { 314 + const unionType = prop.type as Union; 315 + const variants = Array.from(unionType.variants.values()); 316 + 317 + // Check if null is one of the variants 318 + const hasNull = variants.some(v => v.type.kind === "Intrinsic" && (v.type as any).name === "null"); 319 + 320 + if (hasNull) { 321 + // Mark this property as nullable 322 + nullable.push(name); 323 + 324 + // Find the non-null variant 325 + const nonNullVariant = variants.find(v => !(v.type.kind === "Intrinsic" && (v.type as any).name === "null")); 326 + if (nonNullVariant) { 327 + typeToProcess = nonNullVariant.type; 328 + } 329 + } 330 + } 331 + 332 + const propDef = this.typeToLexiconDefinition(typeToProcess, prop); 311 333 if (propDef) { 312 334 properties[name] = propDef; 313 335 } ··· 324 346 325 347 if (required.length > 0) { 326 348 obj.required = required; 349 + } 350 + 351 + if (nullable.length > 0) { 352 + obj.nullable = nullable; 327 353 } 328 354 329 355 obj.properties = properties;
+1 -1
packages/example/.gitignore
··· 2 2 node_modules/ 3 3 4 4 # Generated lexicons 5 - lexicons/ 5 + lexicon/ 6 6 7 7 # TypeSpec output (if using default config) 8 8 tsp-output/
-72
packages/example/lexicon/app/example/defs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.defs", 4 - "defs": { 5 - "postRef": { 6 - "type": "object", 7 - "description": "Reference to a post", 8 - "required": [ 9 - "uri", 10 - "cid" 11 - ], 12 - "properties": { 13 - "uri": { 14 - "type": "string", 15 - "description": "AT URI of the post" 16 - }, 17 - "cid": { 18 - "type": "string", 19 - "description": "CID of the post" 20 - } 21 - } 22 - }, 23 - "replyRef": { 24 - "type": "object", 25 - "description": "Reference to a parent post in a reply chain", 26 - "required": [ 27 - "root", 28 - "parent" 29 - ], 30 - "properties": { 31 - "root": { 32 - "type": "ref", 33 - "ref": "#postRef", 34 - "description": "Root post in the thread" 35 - }, 36 - "parent": { 37 - "type": "ref", 38 - "ref": "#postRef", 39 - "description": "Direct parent post being replied to" 40 - } 41 - } 42 - }, 43 - "entity": { 44 - "type": "object", 45 - "description": "Text entity (mention, link, or tag)", 46 - "required": [ 47 - "start", 48 - "end", 49 - "type", 50 - "value" 51 - ], 52 - "properties": { 53 - "start": { 54 - "type": "integer", 55 - "description": "Start index in text" 56 - }, 57 - "end": { 58 - "type": "integer", 59 - "description": "End index in text" 60 - }, 61 - "type": { 62 - "type": "string", 63 - "description": "Entity type" 64 - }, 65 - "value": { 66 - "type": "string", 67 - "description": "Entity value (handle, URL, or tag)" 68 - } 69 - } 70 - } 71 - } 72 - }
-25
packages/example/lexicon/app/example/follow.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.follow", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "required": [ 8 - "subject", 9 - "createdAt" 10 - ], 11 - "properties": { 12 - "subject": { 13 - "type": "string", 14 - "description": "DID of the account being followed" 15 - }, 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime", 19 - "description": "When the follow was created" 20 - } 21 - } 22 - } 23 - }, 24 - "description": "A follow relationship" 25 - }
-26
packages/example/lexicon/app/example/like.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.like", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "required": [ 8 - "subject", 9 - "createdAt" 10 - ], 11 - "properties": { 12 - "subject": { 13 - "type": "ref", 14 - "ref": "app.example.defs#postRef", 15 - "description": "Post being liked" 16 - }, 17 - "createdAt": { 18 - "type": "string", 19 - "format": "datetime", 20 - "description": "When the like was created" 21 - } 22 - } 23 - } 24 - }, 25 - "description": "A like on a post" 26 - }
-45
packages/example/lexicon/app/example/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.post", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "required": [ 8 - "text", 9 - "createdAt" 10 - ], 11 - "properties": { 12 - "text": { 13 - "type": "string", 14 - "description": "Post text content" 15 - }, 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime", 19 - "description": "Creation timestamp" 20 - }, 21 - "langs": { 22 - "type": "array", 23 - "items": { 24 - "type": "string" 25 - }, 26 - "description": "Languages the post is written in" 27 - }, 28 - "entities": { 29 - "type": "array", 30 - "items": { 31 - "type": "ref", 32 - "ref": "app.example.defs#entity" 33 - }, 34 - "description": "Referenced entities in the post" 35 - }, 36 - "reply": { 37 - "type": "ref", 38 - "ref": "app.example.defs#replyRef", 39 - "description": "Post the user is replying to" 40 - } 41 - } 42 - } 43 - }, 44 - "description": "A post in the feed" 45 - }
-28
packages/example/lexicon/app/example/profile.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.profile", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "properties": { 8 - "displayName": { 9 - "type": "string", 10 - "description": "Display name" 11 - }, 12 - "description": { 13 - "type": "string", 14 - "description": "Profile description" 15 - }, 16 - "avatar": { 17 - "type": "string", 18 - "description": "Profile avatar image" 19 - }, 20 - "banner": { 21 - "type": "string", 22 - "description": "Profile banner image" 23 - } 24 - } 25 - } 26 - }, 27 - "description": "User profile information" 28 - }
-26
packages/example/lexicon/app/example/repost.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.example.repost", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "required": [ 8 - "subject", 9 - "createdAt" 10 - ], 11 - "properties": { 12 - "subject": { 13 - "type": "ref", 14 - "ref": "app.example.defs#postRef", 15 - "description": "Post being reposted" 16 - }, 17 - "createdAt": { 18 - "type": "string", 19 - "format": "datetime", 20 - "description": "When the repost was created" 21 - } 22 - } 23 - } 24 - }, 25 - "description": "A repost of another post" 26 - }