cerulea/lexicon

define atproto schemas in TypeScript

git clone https://git.t4t.associates/cerulea/lexicon

Charlotte Somadd NamedSchema so we can represent cyclic lexicons2d07e5f

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