···11-# tlex Emitter Improvement Plan
22-33-## Philosophy
44-Keep TypeSpec-first design. Use native TypeSpec features where possible, add Lexicon-specific decorators only where necessary. Ensure all schemas can potentially work with other emitters.
55-66-## Immediate Improvements (High Impact)
77-88-### 1. Use TypeSpec stdlib `@minItems`/`@maxItems` ✅
99-**Current:** Using custom `@maxItems` import from compiler
1010-**Change:** Import and use from TypeSpec standard library consistently
1111-**Files:**
1212-- `src/emitter.ts` - Update imports
1313-- Remove custom implementation if any
1414-**Test Impact:** None (already working)
1515-1616-### 2. Add Default Value Support
1717-**Current:** TypeSpec default syntax (`name?: string = "Rex"`) not mapped
1818-**Change:** Emit Lexicon `default` field from TypeSpec default values
1919-**Files:**
2020-- `src/emitter.ts` - Read default values from ModelProperty
2121-- Check for `prop.default` and emit to lexicon
2222-**Test Impact:** Need tests with defaults (but won't match existing lexicons yet)
2323-**Lexicon Support:** boolean, integer, string have `default` field
2424-2525-### 3. Add Enum Constraint Support
2626-**Current:** No way to express closed enums on primitives
2727-**Change:** Detect TypeSpec enum types and emit Lexicon `enum` field
2828-**Files:**
2929-- `src/emitter.ts` - Check if type is Enum, extract values
3030-**Test Impact:** Need new test scenarios
3131-**Lexicon Support:** integer, string have `enum` field (array of values)
3232-3333-### 4. Add Nullable Support
3434-**Current:** Not handling `T | null` unions
3535-**Change:** Detect unions with null, emit to `nullable` array on objects
3636-**Files:**
3737-- `src/emitter.ts` - Check union variants for null type
3838-- Add `nullable` field to object schema
3939-**Test Impact:** Need new test scenarios
4040-**Lexicon Support:** objects have `nullable: string[]` field
4141-4242-## High Value Improvements
4343-4444-### 5. Provide Pre-defined Format Scalars
4545-**Status:** ✅ DONE
4646-**Implementation:** Pre-defined format scalars added (did, handle, atUri, datetime, cid, cidLink, etc.)
4747-**Files:**
4848-- `lib/main.tsp` - Scalar definitions
4949-- `src/emitter.ts` - Detects and maps to format automatically
5050-5151-### 6. Add Const Support for String/Integer
5252-**Current:** Only `@lexConst` for boolean
5353-**Change:** Extend to support string and integer const values
5454-**Files:**
5555-- `lib/decorators.tsp` - Make `@lexConst` accept any value
5656-- `src/decorators.ts` - Handle string/integer
5757-- `src/emitter.ts` - Emit const on string/integer primitives
5858-**Test Impact:** Need new test scenarios
5959-**Lexicon Support:** string, integer, boolean all support `const`
6060-6161-## Nice to Have (Lower Priority)
6262-6363-### 7. Native DateTime Type Mapping
6464-**Status:** ✅ DONE (via `datetime` scalar)
6565-**Implementation:** Use `datetime` scalar instead of `@lexFormat("datetime")`
6666-6767-### 8. Token Type Support
6868-**When needed:** Implement token pattern
6969-**Likely approach:** `@token` decorator on model or scalar
7070-**Not implementing yet:** Wait until we port a lexicon that uses tokens
7171-7272-## Future Major Features
7373-7474-### 9. XRPC Query/Procedure/Subscription Support
7575-**Approach:** Use TypeSpec operations with `@typespec/http` decorators
7676-**Example:**
7777-```typespec
7878-import "@typespec/http";
7979-8080-@route("/xrpc/app.bsky.actor.getProfile")
8181-@get
8282-op getProfile(@query actor: atIdentifier): ProfileViewDetailed;
8383-```
8484-**Not implementing yet:** Focus on record types first
8585-8686-## Implementation Order
8787-8888-### Phase 1: Core Improvements (Keep tests green)
8989-1. ✅ Stdlib `@minItems`/`@maxItems` - Quick win, no test changes
9090-2. Default values - Add support, test separately
9191-3. Enum constraints - Add support, test separately
9292-4. Nullable support - Add support, test separately
9393-9494-### Phase 2: Developer Experience
9595-5. Pre-defined format scalars - Big DX improvement
9696-6. Extend const to string/integer - Complete the feature
9797-9898-### Phase 3: Future (when needed)
9999-7. Native datetime mapping
100100-8. Token types
101101-9. XRPC operations
102102-103103-## Testing Strategy
104104-105105-- Keep ALL existing tests green
106106-- Don't modify output fixtures
107107-- Add NEW test scenarios for new features
108108-- Test features in isolation first
109109-- Combine features once all work independently
110110-111111-## Success Criteria
112112-113113-- All 23 existing tests remain green
114114-- New features have test coverage
115115-- Code is more idiomatic TypeSpec
116116-- Better developer experience with type-safe scalars
117117-- Can still emit identical JSON to atproto/lexicons for ported schemas
···1717extern dec minBytes(target: unknown, value: valueof int32);
18181919/**
2020- * Marks a field as having a constant value.
2121- * Maps to lexicon's "const" field.
2222- * Supports boolean, string, and integer values.
2020+ * Marks a field as read-only with a constant value.
2121+ * Must be used with a default value assignment.
2222+ * Maps to lexicon's "const" field (does NOT emit "default").
2323+ * Only valid for string, boolean, and integer types.
2424+ *
2525+ * @example
2626+ * ```typespec
2727+ * @readOnly
2828+ * foo: string = "fixed-value";
2929+ *
3030+ * @readOnly
3131+ * bar: boolean = true;
3232+ * ```
2333 */
2424-extern dec lexConst(target: unknown, value: unknown);
3434+extern dec readOnly(target: unknown);
25352636/**
2737 * Specifies an enum of allowed values for a field.
+14-30
packages/emitter/src/decorators.ts
···3344const maxGraphemesKey = Symbol("maxGraphemes");
55const minGraphemesKey = Symbol("minGraphemes");
66-const constKey = Symbol("const");
76const enumKey = Symbol("enum");
87const recordKey = Symbol("record");
98const blobKey = Symbol("blob");
109const requiredKey = Symbol("required");
1010+const readOnlyKey = Symbol("readOnly");
1111const tokenKey = Symbol("token");
1212const closedKey = Symbol("closed");
1313const queryKey = Symbol("query");
···124124 target: Type,
125125): number | undefined {
126126 return program.stateMap(minGraphemesKey).get(target);
127127-}
128128-129129-/**
130130- * @lexConst decorator for constant values (boolean, string, or integer)
131131- */
132132-export function $lexConst(
133133- context: DecoratorContext,
134134- target: Type,
135135- value: Type,
136136-) {
137137- const valueAny = value as any;
138138-139139- // Handle different value types
140140- if (valueAny.kind === "Boolean") {
141141- context.program.stateMap(constKey).set(target, valueAny.value);
142142- } else if (valueAny.kind === "String") {
143143- context.program.stateMap(constKey).set(target, valueAny.value);
144144- } else if (valueAny.kind === "Number" || valueAny.kind === "Numeric") {
145145- context.program.stateMap(constKey).set(target, valueAny.value);
146146- } else {
147147- context.program.stateMap(constKey).set(target, value);
148148- }
149149-}
150150-151151-export function getLexConst(
152152- program: Program,
153153- target: Type,
154154-): boolean | string | number | undefined {
155155- return program.stateMap(constKey).get(target);
156127}
157128158129/**
···355326export function isInline(program: Program, target: Type): boolean {
356327 return program.stateSet(inlineKey).has(target);
357328}
329329+330330+/**
331331+ * @readOnly decorator for marking fields with constant values
332332+ * Used with default values to represent lexicon "const" field.
333333+ * Only valid for string, boolean, and integer types.
334334+ */
335335+export function $readOnly(context: DecoratorContext, target: Type) {
336336+ context.program.stateSet(readOnlyKey).add(target);
337337+}
338338+339339+export function isReadOnly(program: Program, target: Type): boolean {
340340+ return program.stateSet(readOnlyKey).has(target);
341341+}
+35-16
packages/emitter/src/emitter.ts
···3232import {
3333 getMaxGraphemes,
3434 getMinGraphemes,
3535- getLexConst,
3635 getLexEnum,
3736 getRecordKey,
3837 isBlob,
3938 isRequired,
3939+ isReadOnly,
4040 isToken,
4141 isClosed,
4242 isQuery,
···10791079 }
1080108010811081 private applyPropertyMetadata(primitive: any, prop: ModelProperty) {
10821082- // Apply const value
10831083- const constValue = getLexConst(this.program, prop);
10841084- if (
10851085- constValue !== undefined &&
10861086- this.isValidConstForType(primitive.type, constValue)
10821082+ // Check if @readOnly is present
10831083+ const hasReadOnly = isReadOnly(this.program, prop);
10841084+10851085+ // Apply default value as const if @readOnly is present
10861086+ const defaultValue = (prop as any).default?.value;
10871087+ if (hasReadOnly) {
10881088+ // Validate that readOnly is only used on string, boolean, or integer
10891089+ if (!this.isValidConstForType(primitive.type, defaultValue)) {
10901090+ this.program.reportDiagnostic({
10911091+ code: "invalid-readonly-type",
10921092+ severity: "error",
10931093+ message: `@readOnly is only valid for string, boolean, and integer types, but found type: ${primitive.type}`,
10941094+ target: prop,
10951095+ });
10961096+ return;
10971097+ }
10981098+10991099+ if (defaultValue === undefined) {
11001100+ this.program.reportDiagnostic({
11011101+ code: "readonly-missing-default",
11021102+ severity: "error",
11031103+ message: "@readOnly requires a default value assignment",
11041104+ target: prop,
11051105+ });
11061106+ return;
11071107+ }
11081108+11091109+ // Set const value from default, don't emit default field
11101110+ primitive.const = defaultValue;
11111111+ } else if (
11121112+ defaultValue !== undefined &&
11131113+ this.isValidDefaultForType(primitive.type, defaultValue)
10871114 ) {
10881088- primitive.const = constValue;
11151115+ // Normal default value (no @readOnly)
11161116+ primitive.default = defaultValue;
10891117 }
1090111810911119 // Apply enum values
10921120 const enumValues = getLexEnum(this.program, prop);
10931121 if (enumValues !== undefined && enumValues.length > 0) {
10941122 primitive.enum = enumValues;
10951095- }
10961096-10971097- // Apply default value
10981098- const defaultValue = (prop as any).default?.value;
10991099- if (
11001100- defaultValue !== undefined &&
11011101- this.isValidDefaultForType(primitive.type, defaultValue)
11021102- ) {
11031103- primitive.default = defaultValue;
11041123 }
11051124 }
11061125
···124124 model NotFoundActor {
125125 @required actor: atIdentifier;
126126127127- @lexConst(true)
127127+ @readOnly
128128 @required
129129- notFound: boolean;
129129+ notFound: boolean = true;
130130 }
131131132132 @doc("lists the bi-directional graph relationships between one actor (not indicated in the object), and the target actors (the DID included in the object)")