[Breaking] Add @default decorator, uninline scalars by default #5

merged
opened by danabra.mov targeting main from feat/scalar-defs-and-default
Changed files
+766 -3
packages
emitter
src
test
integration
atproto
input
app
bsky
actor
lexicon-examples
input
community
lexicon
calendar
output
community
lexicon
spec
+17
packages/emitter/src/decorators.ts
···
const maxBytesKey = Symbol("maxBytes");
const minBytesKey = Symbol("minBytes");
const externalKey = Symbol("external");
+
const defaultKey = Symbol("default");
/**
* @maxBytes decorator for maximum length of bytes type
···
return program.stateSet(readOnlyKey).has(target);
}
+
/**
+
* @default decorator for setting default values on scalars and unions
+
* The value can be a literal (string, number, boolean) or a model reference for tokens
+
*/
+
export function $default(context: DecoratorContext, target: Type, value: any) {
+
// Just store the raw value - let the emitter handle unwrapping and validation
+
context.program.stateMap(defaultKey).set(target, value);
+
}
+
+
export function getDefault(
+
program: Program,
+
target: Type,
+
): any | undefined {
+
return program.stateMap(defaultKey).get(target);
+
}
+
/**
* @external decorator for marking a namespace as external
* External namespaces are skipped during emission and don't produce JSON files
+2
packages/emitter/src/tsp-index.ts
···
$maxBytes,
$minBytes,
$external,
+
$default,
} from "./decorators.js";
/** @internal */
···
maxBytes: $maxBytes,
minBytes: $minBytes,
external: $external,
+
default: $default,
},
};
+2
packages/emitter/test/integration/atproto/input/app/bsky/actor/defs.tsp
···
prioritizeFollowedUsers?: boolean;
}
+
@inline
@maxLength(640)
@maxGraphemes(64)
scalar InterestTag extends string;
···
@required did: did;
}
+
@inline
@maxLength(100)
scalar NudgeToken extends string;
+30
packages/emitter/test/spec/basic/input/com/example/scalarDefaults.tsp
···
+
import "@typelex/emitter";
+
+
namespace com.example.scalarDefaults {
+
/** Test default decorator on scalars */
+
model Main {
+
/** Uses string scalar with default */
+
mode?: Mode;
+
+
/** Uses integer scalar with default */
+
limit?: Limit;
+
+
/** Uses boolean scalar with default */
+
enabled?: Enabled;
+
}
+
+
/** A string type with a default value */
+
@default("standard")
+
@maxLength(50)
+
scalar Mode extends string;
+
+
/** An integer type with a default value */
+
@default(50)
+
@minValue(1)
+
@maxValue(100)
+
scalar Limit extends integer;
+
+
/** A boolean type with a default value */
+
@default(true)
+
scalar Enabled extends boolean;
+
}
+22
packages/emitter/test/spec/basic/input/com/example/scalarDefs.tsp
···
+
import "@typelex/emitter";
+
+
namespace com.example.scalarDefs {
+
/** Scalar defs should create standalone defs like models and unions */
+
model Main {
+
/** Uses a custom string scalar with constraints */
+
tag?: Tag;
+
+
/** Uses a custom integer scalar with constraints */
+
count?: Count;
+
}
+
+
/** A custom string type with length constraints */
+
@maxLength(100)
+
@maxGraphemes(50)
+
scalar Tag extends string;
+
+
/** A custom integer type with value constraints */
+
@minValue(1)
+
@maxValue(100)
+
scalar Count extends integer;
+
}
+22
packages/emitter/test/spec/basic/input/com/example/scalarInline.tsp
···
+
import "@typelex/emitter";
+
+
namespace com.example.scalarInline {
+
/** Test inline decorator on scalars */
+
model Main {
+
/** Inline scalar - should not create a def */
+
tag?: Tag;
+
+
/** Non-inline scalar - should create a def */
+
category?: Category;
+
}
+
+
/** An inline scalar should be inlined at usage sites */
+
@inline
+
@maxLength(50)
+
@maxGraphemes(25)
+
scalar Tag extends string;
+
+
/** A regular scalar should create a standalone def */
+
@maxLength(100)
+
scalar Category extends string;
+
}
+53
packages/emitter/test/spec/basic/input/com/example/unionDefaults.tsp
···
+
import "@typelex/emitter";
+
+
namespace com.example.unionDefaults {
+
/** Test default decorator on unions */
+
model Main {
+
/** Union with token refs and default */
+
eventMode?: EventMode;
+
+
/** Union with string literals and default */
+
sortOrder?: SortOrder;
+
+
/** Union with integer literals and default */
+
priority?: Priority;
+
}
+
+
/** Union of tokens with default pointing to a token */
+
@default(Inperson)
+
union EventMode {
+
Hybrid,
+
Inperson,
+
Virtual,
+
string,
+
}
+
+
/** A hybrid event */
+
@token
+
model Hybrid {}
+
+
/** An in-person event */
+
@token
+
model Inperson {}
+
+
/** A virtual event */
+
@token
+
model Virtual {}
+
+
/** Union of string literals with default */
+
@default("asc")
+
union SortOrder {
+
"asc",
+
"desc",
+
string,
+
}
+
+
/** Union of integer literals with default (closed enum) */
+
@default(1)
+
@closed
+
union Priority {
+
1,
+
2,
+
3,
+
}
+
}
+45
packages/emitter/test/spec/basic/output/com/example/scalarDefaults.json
···
+
{
+
"lexicon": 1,
+
"id": "com.example.scalarDefaults",
+
"defs": {
+
"main": {
+
"type": "object",
+
"properties": {
+
"mode": {
+
"type": "ref",
+
"ref": "#mode",
+
"description": "Uses string scalar with default"
+
},
+
"limit": {
+
"type": "ref",
+
"ref": "#limit",
+
"description": "Uses integer scalar with default"
+
},
+
"enabled": {
+
"type": "ref",
+
"ref": "#enabled",
+
"description": "Uses boolean scalar with default"
+
}
+
},
+
"description": "Test default decorator on scalars"
+
},
+
"mode": {
+
"type": "string",
+
"maxLength": 50,
+
"default": "standard",
+
"description": "A string type with a default value"
+
},
+
"limit": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"default": 50,
+
"description": "An integer type with a default value"
+
},
+
"enabled": {
+
"type": "boolean",
+
"default": true,
+
"description": "A boolean type with a default value"
+
}
+
}
+
}
+34
packages/emitter/test/spec/basic/output/com/example/scalarDefs.json
···
+
{
+
"lexicon": 1,
+
"id": "com.example.scalarDefs",
+
"defs": {
+
"main": {
+
"type": "object",
+
"properties": {
+
"tag": {
+
"type": "ref",
+
"ref": "#tag",
+
"description": "Uses a custom string scalar with constraints"
+
},
+
"count": {
+
"type": "ref",
+
"ref": "#count",
+
"description": "Uses a custom integer scalar with constraints"
+
}
+
},
+
"description": "Scalar defs should create standalone defs like models and unions"
+
},
+
"tag": {
+
"type": "string",
+
"maxLength": 100,
+
"maxGraphemes": 50,
+
"description": "A custom string type with length constraints"
+
},
+
"count": {
+
"type": "integer",
+
"minimum": 1,
+
"maximum": 100,
+
"description": "A custom integer type with value constraints"
+
}
+
}
+
}
+28
packages/emitter/test/spec/basic/output/com/example/scalarInline.json
···
+
{
+
"lexicon": 1,
+
"id": "com.example.scalarInline",
+
"defs": {
+
"main": {
+
"type": "object",
+
"properties": {
+
"tag": {
+
"type": "string",
+
"maxLength": 50,
+
"maxGraphemes": 25,
+
"description": "Inline scalar - should not create a def"
+
},
+
"category": {
+
"type": "ref",
+
"ref": "#category",
+
"description": "Non-inline scalar - should create a def"
+
}
+
},
+
"description": "Test inline decorator on scalars"
+
},
+
"category": {
+
"type": "string",
+
"maxLength": 100,
+
"description": "A regular scalar should create a standalone def"
+
}
+
}
+
}
+61
packages/emitter/test/spec/basic/output/com/example/unionDefaults.json
···
+
{
+
"lexicon": 1,
+
"id": "com.example.unionDefaults",
+
"defs": {
+
"main": {
+
"type": "object",
+
"properties": {
+
"eventMode": {
+
"type": "ref",
+
"ref": "#eventMode",
+
"description": "Union with token refs and default"
+
},
+
"sortOrder": {
+
"type": "ref",
+
"ref": "#sortOrder",
+
"description": "Union with string literals and default"
+
},
+
"priority": {
+
"type": "ref",
+
"ref": "#priority",
+
"description": "Union with integer literals and default"
+
}
+
},
+
"description": "Test default decorator on unions"
+
},
+
"eventMode": {
+
"type": "string",
+
"knownValues": [
+
"com.example.unionDefaults#hybrid",
+
"com.example.unionDefaults#inperson",
+
"com.example.unionDefaults#virtual"
+
],
+
"default": "com.example.unionDefaults#inperson",
+
"description": "Union of tokens with default pointing to a token"
+
},
+
"hybrid": {
+
"type": "token",
+
"description": "A hybrid event"
+
},
+
"inperson": {
+
"type": "token",
+
"description": "An in-person event"
+
},
+
"virtual": {
+
"type": "token",
+
"description": "A virtual event"
+
},
+
"sortOrder": {
+
"type": "string",
+
"knownValues": ["asc", "desc"],
+
"default": "asc",
+
"description": "Union of string literals with default"
+
},
+
"priority": {
+
"type": "integer",
+
"enum": [1, 2, 3],
+
"default": 1,
+
"description": "Union of integer literals with default (closed enum)"
+
}
+
}
+
}
+164 -1
DOCS.md
···
Note that `Caption` won't exist as a separate def—the abstraction is erased in the output.
+
### Scalars
+
+
TypeSpec scalars let you create named types with constraints. **By default, scalars create standalone defs** (like models):
+
+
```typescript
+
import "@typelex/emitter";
+
+
namespace com.example {
+
model Main {
+
handle?: Handle;
+
bio?: Bio;
+
}
+
+
@maxLength(50)
+
scalar Handle extends string;
+
+
@maxLength(256)
+
@maxGraphemes(128)
+
scalar Bio extends string;
+
}
+
```
+
+
This creates three defs: `main`, `handle`, and `bio`:
+
+
```json
+
{
+
"id": "com.example",
+
"defs": {
+
"main": {
+
"type": "object",
+
"properties": {
+
"handle": { "type": "ref", "ref": "#handle" },
+
"bio": { "type": "ref", "ref": "#bio" }
+
}
+
},
+
"handle": {
+
"type": "string",
+
"maxLength": 50
+
},
+
"bio": {
+
"type": "string",
+
"maxLength": 256,
+
"maxGraphemes": 128
+
}
+
}
+
}
+
```
+
+
Use `@inline` to expand a scalar inline instead:
+
+
```typescript
+
import "@typelex/emitter";
+
+
namespace com.example {
+
model Main {
+
handle?: Handle;
+
}
+
+
@inline
+
@maxLength(50)
+
scalar Handle extends string;
+
}
+
```
+
+
Now `Handle` is expanded inline (no separate def):
+
+
```json
+
// ...
+
"properties": {
+
"handle": { "type": "string", "maxLength": 50 }
+
}
+
// ...
+
```
+
## Top-Level Lexicon Types
TypeSpec uses `model` for almost everything. Decorators specify what kind of Lexicon type it becomes.
···
## Defaults and Constants
-
### Defaults
+
### Property Defaults
+
+
You can set default values on properties:
```typescript
import "@typelex/emitter";
···
Maps to: `{"default": 1}`, `{"default": "en"}`
+
### Type Defaults
+
+
You can also set defaults on scalar and union types using the `@default` decorator:
+
+
```typescript
+
import "@typelex/emitter";
+
+
namespace com.example {
+
model Main {
+
mode?: Mode;
+
priority?: Priority;
+
}
+
+
@default("standard")
+
scalar Mode extends string;
+
+
@default(1)
+
@closed
+
@inline
+
union Priority { 1, 2, 3 }
+
}
+
```
+
+
This creates a default on the type definition itself:
+
+
```json
+
{
+
"defs": {
+
"mode": {
+
"type": "string",
+
"default": "standard"
+
}
+
}
+
}
+
```
+
+
For unions with token references, pass the model directly:
+
+
```typescript
+
import "@typelex/emitter";
+
+
namespace com.example {
+
model Main {
+
eventType?: EventType;
+
}
+
+
@default(InPerson)
+
union EventType { Hybrid, InPerson, Virtual, string }
+
+
@token model Hybrid {}
+
@token model InPerson {}
+
@token model Virtual {}
+
}
+
```
+
+
This resolves to the fully-qualified token NSID:
+
+
```json
+
{
+
"eventType": {
+
"type": "string",
+
"knownValues": [
+
"com.example#hybrid",
+
"com.example#inPerson",
+
"com.example#virtual"
+
],
+
"default": "com.example#inPerson"
+
}
+
}
+
```
+
+
**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:
+
+
```typescript
+
@default("standard")
+
scalar Mode extends string;
+
+
model Main {
+
mode?: Mode = "custom"; // ERROR: Conflicting defaults!
+
}
+
```
+
+
Solutions:
+
1. Make the defaults match: `mode?: Mode = "standard"`
+
2. Mark the type `@inline`: Allows property-level defaults
+
3. Remove the property default: Uses the type's default
+
### Constants
Use `@readOnly` with a default:
+125
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/event.tsp
···
+
import "@typelex/emitter";
+
+
namespace community.lexicon.calendar.event {
+
/** A calendar event. */
+
@rec("tid")
+
model Main {
+
/** The name of the event. */
+
@required
+
name: string;
+
+
/** The description of the event. */
+
description?: string;
+
+
/** Client-declared timestamp when the event was created. */
+
@required
+
createdAt: datetime;
+
+
/** Client-declared timestamp when the event starts. */
+
startsAt?: datetime;
+
+
/** Client-declared timestamp when the event ends. */
+
endsAt?: datetime;
+
+
/** The attendance mode of the event. */
+
mode?: Mode;
+
+
/** The status of the event. */
+
status?: Status;
+
+
/** The locations where the event takes place. */
+
locations?: (
+
| Uri
+
| community.lexicon.location.address.Main
+
| community.lexicon.location.fsq.Main
+
| community.lexicon.location.geo.Main
+
| community.lexicon.location.hthree.Main
+
)[];
+
+
/** URIs associated with the event. */
+
uris?: Uri[];
+
}
+
+
/** The mode of the event. */
+
@default(Inperson)
+
union Mode {
+
Hybrid,
+
Inperson,
+
Virtual,
+
string,
+
}
+
+
/** A virtual event that takes place online. */
+
@token
+
model Virtual {}
+
+
/** An in-person event that takes place offline. */
+
@token
+
model Inperson {}
+
+
/** A hybrid event that takes place both online and offline. */
+
@token
+
model Hybrid {}
+
+
/** The status of the event. */
+
@default(Scheduled)
+
union Status {
+
Cancelled,
+
Planned,
+
Postponed,
+
Rescheduled,
+
Scheduled,
+
string,
+
}
+
+
/** The event has been created, but not finalized. */
+
@token
+
model Planned {}
+
+
/** The event has been created and scheduled. */
+
@token
+
model Scheduled {}
+
+
/** The event has been rescheduled. */
+
@token
+
model Rescheduled {}
+
+
/** The event has been cancelled. */
+
@token
+
model Cancelled {}
+
+
/** The event has been postponed and a new start date has not been set. */
+
@token
+
model Postponed {}
+
+
/** A URI associated with the event. */
+
model Uri {
+
@required
+
uri: uri;
+
+
/** The display name of the URI. */
+
name?: string;
+
}
+
}
+
+
// --- Externals ---
+
+
@external
+
namespace community.lexicon.location.address {
+
model Main {}
+
}
+
+
@external
+
namespace community.lexicon.location.fsq {
+
model Main {}
+
}
+
+
@external
+
namespace community.lexicon.location.geo {
+
model Main {}
+
}
+
+
@external
+
namespace community.lexicon.location.hthree {
+
model Main {}
+
}
+41
packages/emitter/test/integration/lexicon-examples/input/community/lexicon/calendar/rsvp.tsp
···
+
import "@typelex/emitter";
+
+
namespace community.lexicon.calendar.rsvp {
+
/** An RSVP for an event. */
+
@rec("tid")
+
model Main {
+
@required
+
subject: `com`.atproto.repo.strongRef.Main;
+
+
@required
+
status: Status;
+
}
+
+
@inline
+
@default(Going)
+
union Status {
+
Interested,
+
Going,
+
Notgoing,
+
string,
+
}
+
+
/** Interested in the event */
+
@token
+
model Interested {}
+
+
/** Going to the event */
+
@token
+
model Going {}
+
+
/** Not going to the event */
+
@token
+
model Notgoing {}
+
}
+
+
// --- Externals ---
+
+
@external
+
namespace `com`.atproto.repo.strongRef {
+
model Main {}
+
}
+119 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/event.json
···
"lexicon": 1,
"id": "community.lexicon.calendar.event",
"defs": {
+
"main": {
+
"type": "record",
+
"description": "A calendar event.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"name",
+
"createdAt"
+
],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "The name of the event."
+
},
+
"description": {
+
"type": "string",
+
"description": "The description of the event."
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Client-declared timestamp when the event was created."
+
},
+
"startsAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Client-declared timestamp when the event starts."
+
},
+
"endsAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Client-declared timestamp when the event ends."
+
},
+
"mode": {
+
"type": "ref",
+
"ref": "#mode",
+
"description": "The attendance mode of the event."
+
},
+
"status": {
+
"type": "ref",
+
"ref": "#status",
+
"description": "The status of the event."
+
},
+
"locations": {
+
"type": "array",
+
"description": "The locations where the event takes place.",
+
"items": {
+
"type": "union",
+
"refs": [
+
"#uri",
+
"community.lexicon.location.address",
+
"community.lexicon.location.fsq",
+
"community.lexicon.location.geo",
+
"community.lexicon.location.hthree"
+
]
+
}
+
},
+
"uris": {
+
"type": "array",
+
"description": "URIs associated with the event.",
+
"items": {
+
"type": "ref",
+
"ref": "#uri"
+
}
+
}
+
}
+
}
+
},
"mode": {
"type": "string",
"description": "The mode of the event.",
···
"hybrid": {
"type": "token",
"description": "A hybrid event that takes place both online and offline."
+
},
+
"status": {
+
"type": "string",
+
"description": "The status of the event.",
+
"default": "community.lexicon.calendar.event#scheduled",
+
"knownValues": [
+
"community.lexicon.calendar.event#cancelled",
+
"community.lexicon.calendar.event#planned",
+
"community.lexicon.calendar.event#postponed",
+
"community.lexicon.calendar.event#rescheduled",
+
"community.lexicon.calendar.event#scheduled"
+
]
+
},
+
"planned": {
+
"type": "token",
+
"description": "The event has been created, but not finalized."
+
},
+
"scheduled": {
+
"type": "token",
+
"description": "The event has been created and scheduled."
+
},
+
"rescheduled": {
+
"type": "token",
+
"description": "The event has been rescheduled."
+
},
+
"cancelled": {
+
"type": "token",
+
"description": "The event has been cancelled."
+
},
+
"postponed": {
+
"type": "token",
+
"description": "The event has been postponed and a new start date has not been set."
+
},
+
"uri": {
+
"type": "object",
+
"description": "A URI associated with the event.",
+
"required": [
+
"uri"
+
],
+
"properties": {
+
"uri": {
+
"type": "string",
+
"format": "uri"
+
},
+
"name": {
+
"type": "string",
+
"description": "The display name of the URI."
+
}
+
}
}
}
-
}
+
}
+1 -1
packages/emitter/test/integration/lexicon-examples/output/community/lexicon/calendar/rsvp.json
···
"description": "Not going to the event"
}
}
-
}
+
}