cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
2d07e5f
main
1import * as l from "../mod.ts" ; 2 3const Facet = l . union ( "blue.cerulea.app.facet" , { 4"#mention" :l . object ({ start :l . integer , end :l . integer , did :l . did }), 5"#link" :l . object ({ start :l . integer , end :l . integer , uri :l . uri }), 6}); 7const Post = l . record ( "blue.cerulea.app.post" , { key :"tid" }, { 8text :l . string , 9facets :l . array ( Facet ), 10createdAt :l . optional ( l . datetime ), 11nullable :l . nullable ( l . string ), 12both :l . optional ( l . nullable ( l . string )), 13}); 14 15type Equal < A , B > = ( < T > () => T extends A ?1 :2 ) extends ( < T > () => T extends B ?1 :2 ) ?true 16 :false ; 17type Expect < T extends true > = T ; 18 19export type DiscriminatedInference = [ 20Expect < 21Equal < 22l . Infer < typeof Facet >, 23| { 24readonly $type :"blue.cerulea.app.facet#mention" ; 25readonly start :number ; 26readonly end :number ; 27readonly did : `did:${string } `; 28} 29| { 30readonly $type :"blue.cerulea.app.facet#link" ; 31readonly start :number ; 32readonly end :number ; 33readonly uri :string ; 34} 35> 36>, 37Expect < 38Equal < l . Infer < typeof Post >, { 39readonly $type :"blue.cerulea.app.post" ; 40readonly text :string ; 41readonly facets :readonly l . Infer < typeof Facet >[]; 42readonly createdAt ?:string ; 43readonly nullable :string | null ; 44readonly both ?:string | null ; 45}> 46>, 47Expect < 48Equal < 49l . Input < typeof Facet >, 50| { 51readonly $type :"blue.cerulea.app.facet#mention" | "#mention" ; 52readonly start :number ; 53readonly end :number ; 54readonly did : `did:${string } `; 55} 56| { 57readonly $type :"blue.cerulea.app.facet#link" | "#link" ; 58readonly start :number ; 59readonly end :number ; 60readonly uri :string ; 61} 62> 63>, 64]; 65 66// This function is type-checked but never called. 67export function inference ( post :l . Infer < typeof Post >, facet :l . Infer < typeof Facet >) :void { 68const p :l . Infer < typeof Post > = { 69$type :"blue.cerulea.app.post" , 70text :"" , 71facets :[], 72nullable :null , 73}; 74const built :l . Infer < typeof Post > = Post . build ({ 75text :"" , 76facets :[], 77nullable :null , 78}); 79void built ; 80// @ts-expect-error Required record fields cannot be omitted. 81Post . build ({ text :"" , facets :[] }); 82// @ts-expect-error Record fields must not supply their own discriminator. 83Post . build ({ text :"" , facets :[], nullable :null , $type :Post . id }); 84// @ts-expect-error Only records have builders. 85l . object ({ text :l . string }). build ({ text :"" }); 86 87const optional :string | undefined = p . createdAt ; 88const nullable :string | null = p . nullable ; 89const both :string | null | undefined = p . both ; 90void [ optional , nullable , both ]; 91const checked = Post . validate ( post ); 92if ( checked . success ) { 93const value :l . Infer < typeof Post > = checked . value ; 94void value ; 95} 96 97// @ts-expect-error Records require their inferred discriminator. 98const missing :l . Infer < typeof Post > = { text :"" , facets :[], nullable :null }; 99const undefinedField :l . Infer < typeof Post > = { ...p , createdAt :undefined }; 100// @ts-expect-error Non-nullable optional fields do not accept null. 101const nullField :l . Infer < typeof Post > = { ...p , createdAt :null }; 102// @ts-expect-error Nullable fields are still required. 103const missingNullable :l . Infer < typeof Post > = { 104$type :"blue.cerulea.app.post" , 105text :"" , 106facets :[], 107}; 108void [ missing , undefinedField , nullField , missingNullable ]; 109 110// @ts-expect-error Inferred record properties are readonly. 111post . text = "changed" ; 112// @ts-expect-error Inferred arrays are readonly. 113post . facets . push ( facet ); 114// @ts-expect-error Union discriminators cannot be changed. 115facet . $type = "blue.cerulea.app.facet#mention" ; 116 117if ( facet . $type === "blue.cerulea.app.facet#mention" ) { 118const did : `did:${string } `= facet . did ; 119void did ; 120// @ts-expect-error Mention narrowing excludes link fields. 121void facet . uri ; 122} else { 123const uri :string = facet . uri ; 124void uri ; 125// @ts-expect-error Link narrowing excludes mention fields. 126void facet . did ; 127} 128 129const named = l . named ( "blue.cerulea.app.defs#facetHolder" , l . object ({ facet :Facet })); 130const value :l . Infer < typeof named > = { facet}; 131// @ts-expect-error Named object refs do not add a discriminator. 132void value . $type ; 133 134const open = l . union ( "blue.cerulea.app.open" , { "#known" :l . object ({ text :l . string }) }, { 135closed :false , 136}); 137const future :l . Infer < typeof open > = { $type :"other.example.future" , anything :42 }; 138void future ; 139 140const words = l . enumValues ( "yes" , "no" ); 141const word :l . Infer < typeof words > = "yes" ; 142// @ts-expect-error Enums retain literal inference. 143const invalidWord :l . Infer < typeof words > = "maybe" ; 144void [ word , invalidWord ]; 145 146const result = l . compile ( Post )( post ); 147if ( result . success ) { 148const sameType :l . Infer < typeof Post > = result . value ; 149void sameType ; 150} 151 152// @ts-expect-error Optionality is only allowed on object properties. 153l . array ( l . optional ( l . string )); 154// @ts-expect-error Nullability is only allowed on object properties. 155l . array ( l . nullable ( l . string )); 156// @ts-expect-error Variants must be objects. 157l . union ( "blue.cerulea.app.facet" , { "#text" :l . string }); 158} 159 160// This function is type-checked but never called. 161export function building ( facet :l . Infer < typeof Facet >) :void { 162const built :l . Infer < typeof Post > = Post . build ({ 163text :"" , 164facets :[ 165{ $type :"#mention" , start :0 , end :1 , did :"did:plc:alice" }, 166{ $type :"blue.cerulea.app.facet#link" , start :0 , end :1 , uri :"" }, 167facet , 168], 169nullable :null , 170}); 171const single :l . Infer < typeof Facet > = Facet . build ({ $type :"#link" , start :0 , end :1 , uri :"" }); 172void [ built , single ]; 173// @ts-expect-error Short variants must name a declared variant. 174Facet . build ({ $type :"#other" , start :0 , end :1 , uri :"" }); 175// @ts-expect-error Short variants narrow to their variant's fields. 176Facet . build ({ $type :"#mention" , start :0 , end :1 , did :"did:plc:alice" , uri :"" }); 177// @ts-expect-error Stored values need full discriminators. 178const short :l . Infer < typeof Facet > = { $type :"#link" , start :0 , end :1 , uri :"" }; 179void short ; 180 181const anonymous = l . union ({ "blue.cerulea.app.facet#link" :l . object ({ uri :l . uri }) }); 182anonymous . build ({ $type :"blue.cerulea.app.facet#link" , uri :"" }); 183// @ts-expect-error Only namespaced unions accept short variants. 184anonymous . build ({ $type :"#link" , uri :"" }); 185 186const Embed = l . union ( "blue.cerulea.app.embed" , { 187"#facets" :l . object ({ facets :l . array ( Facet ) }), 188"#post" :l . object ({ post :Post }), 189}); 190const Holder = l . record ( "blue.cerulea.app.holder" , { key :"tid" }, { 191embed :l . optional ( Embed ), 192pinned :l . named ( "blue.cerulea.app.defs#pin" , l . object ({ facet :l . nullable ( Facet ) })), 193}); 194Holder . build ({ 195embed :{ $type :"#facets" , facets :[{ $type :"#link" , start :0 , end :1 , uri :"" }] }, 196pinned :{ facet :{ $type :"#mention" , start :0 , end :1 , did :"did:plc:alice" } }, 197}); 198Holder . build ({ 199embed :{ $type :"#post" , post :{ text :"" , facets :[], nullable :null } }, 200pinned :{ facet :null }, 201}); 202Holder . build ({ 203embed :{ $type :"#post" , post :{ $type :Post . id , text :"" , facets :[], nullable :null } }, 204pinned :{ facet :null }, 205}); 206Holder . build ({ 207embed :{ 208$type :"#post" , 209// @ts-expect-error Nested records only accept their own discriminator. 210post :{ $type :"blue.cerulea.app.other" , text :"" , facets :[], nullable :null }, 211}, 212pinned :{ facet :null }, 213}); 214 215const open = l . union ( "blue.cerulea.app.open" , { "#known" :l . object ({ text :l . string }) }, { 216closed :false , 217}); 218open . build ({ $type :"#known" , text :"" }); 219open . build ({ $type :"other.example.future" , anything :42 }); 220} 221 222const EagerNamed = l . named ( 223"blue.cerulea.app.defs#eager" , 224l . object ({ facet :Facet }), 225); 226const LazyNamed = l . named ( 227"blue.cerulea.app.defs#lazy" , 228() => l . object ({ facet :Facet }), 229); 230const NamedUnion = l . union ([ EagerNamed , LazyNamed ]); 231 232export type NamedInference = [ 233Expect < Equal < l . Infer < typeof EagerNamed >, l . Infer < typeof LazyNamed >>>, 234Expect < Equal < l . Input < typeof EagerNamed >, l . Input < typeof LazyNamed >>>, 235Expect < Equal < typeof LazyNamed . id , "blue.cerulea.app.defs#lazy" >>, 236Expect < Equal < l . Infer < typeof LazyNamed >[ "facet" ], l . Infer < typeof Facet >>>, 237Expect < Equal < l . Input < typeof LazyNamed >[ "facet" ], l . Input < typeof Facet >>>, 238Expect < 239Equal < 240l . Infer < typeof NamedUnion >, 241| { 242readonly $type :"blue.cerulea.app.defs#eager" ; 243readonly facet :l . Infer < typeof Facet >; 244} 245| { 246readonly $type :"blue.cerulea.app.defs#lazy" ; 247readonly facet :l . Infer < typeof Facet >; 248} 249> 250>, 251Expect < 252Equal < 253l . Input < typeof NamedUnion >, 254| { 255readonly $type :"blue.cerulea.app.defs#eager" ; 256readonly facet :l . Input < typeof Facet >; 257} 258| { 259readonly $type :"blue.cerulea.app.defs#lazy" ; 260readonly facet :l . Input < typeof Facet >; 261} 262> 263>, 264]; 265 266export function namedInference () :void { 267const Text = l . named ( 268"blue.cerulea.app.defs#text" , 269() => l . object ({ text :l . string }), 270); 271const Count = l . named ( 272"blue.cerulea.app.defs#count" , 273l . object ({ count :l . integer }), 274); 275const Result = l . union ([ Text , Count ]); 276Result . build ({ $type :Text . id , text :"hello" }); 277// @ts-expect-error Named union discriminators select their own fields. 278Result . build ({ $type :Text . id , count :1 }); 279// @ts-expect-error Array unions have no namespace for short discriminators. 280Result . build ({ $type :"#text" , text :"hello" }); 281// @ts-expect-error Array unions require named definitions. 282l . union ([ l . object ({})]); 283const value = Result . build ({ $type :Count . id , count :1 }); 284if ( value . $type === Count . id ) { 285const count :number = value . count ; 286void count ; 287// @ts-expect-error Discriminator narrowing excludes the other variant's fields. 288void value . text ; 289} 290 291type Tree = { readonly text :string ; readonly children ?:readonly Tree [] }; 292const Tree :l . NamedSchema < "blue.cerulea.app.defs#tree" , Tree > = l . named ( 293"blue.cerulea.app.defs#tree" , 294() => l . object ({ text :l . string , children :l . optional ( l . array ( Tree )) }), 295); 296const Holder = l . record ( "blue.cerulea.app.tree" , { key :"tid" }, { 297tree :Tree , 298}); 299Holder . build ({ tree :{ text :"root" , children :[{ text :"leaf" }] } }); 300// @ts-expect-error Explicit recursive annotations still check nested fields. 301Holder . build ({ tree :{ text :"root" , children :[{ text :1 }] } }); 302// @ts-expect-error A recursive annotation must agree with the factory's schema. 303const Wrong :l . NamedSchema < "blue.cerulea.app.defs#wrong" , Tree > = l . named ( 304"blue.cerulea.app.defs#wrong" , 305() => l . object ({ text :l . integer }), 306); 307void Wrong ; 308}