Skip to main content

bynk_emit/emitter/
client.rs

1//! I4 (#1970): the typed TypeScript client for a context's foreign callers,
2//! `workers/<context>/bynk-client.ts` (`design/archive/retired-tracks.md`, interop track D3).
3//!
4//! A Bynk caller of another context gets a typed call: the compiler builds the
5//! path, stamps the contract hash, encodes the arguments and decodes the
6//! result. This module gives a Worker that is not Bynk the same, as a module it
7//! imports: one typed function per `on call` service a declared foreign caller
8//! may call, the boundary types as TypeScript types, and the contract hashes
9//! and wire version embedded as literals.
10//!
11//! # One codec, one caller
12//!
13//! Nothing here is a second implementation of the boundary. The codecs are the
14//! ones a Bynk *consumer* generates for a callee's types
15//! (`serialisation::emit_helpers_for_owner_qualified` under a non-empty
16//! qualifier, which inlines a transparent refinement and casts an opaque
17//! one), each call goes through the runtime's own `callService`, and the
18//! encode and decode references at each call site come from the same
19//! `serialisation::serialise_expr_via` / `serialisation::deserialise_ref_via`
20//! the Bynk call site uses (`emit.rs`'s cross-context call lowering). The
21//! runtime half ships as `@bynk/wire-client`, bundled from the runtime's own
22//! modules and versioned with the wire format (§11 Q7).
23//!
24//! # Signing as a foreign caller
25//!
26//! I3's hand-off gives the foreign Worker its secret under the name a Bynk
27//! caller holds its edge key by, `BYNK_CALL_TO_<CALLEE>`, and the callee's
28//! verifier checks a declared foreign caller's name against its own secret
29//! (`__FOREIGN_CALLERS`). So `callService` with the actor's name as the caller
30//! signs exactly the request the callee admits. The client asks for the
31//! foreign Worker's `env` (typed to require the secret) and its Service Binding.
32//!
33//! # When it is emitted
34//!
35//! Only for a context that declares at least one `Foreign` actor some `on call`
36//! handler admits, and only with the services such an actor may call. A
37//! context with no foreign caller has no one the client could sign as: an
38//! undeclared caller holds no secret (§8 D2, §11 Q5).
39
40use std::collections::{BTreeMap, BTreeSet, HashMap};
41use std::sync::Arc;
42
43use bynk_check::project_model::TypeDecl;
44use bynk_check::resolver::{CrossContextService, cross_context_service_for};
45use bynk_check::symbols::UnitTable;
46use bynk_check::wire::{TypeBody, TypeRef};
47use bynk_ts::{
48    TsDecl, TsExpr, TsLit, TsObjectEntry, TsParam, TsProgram, TsStmt, TsType, TsTypeMember,
49};
50
51use super::runtime_use::RuntimeUse;
52use super::serialisation;
53
54/// The file, beside the Worker's `bynk-contracts.json`. Hyphenated like the
55/// other compiler-written files there, so no Bynk unit can emit a module of
56/// the same name.
57pub const CLIENT_FILE: &str = "bynk-client.ts";
58
59/// The runtime package the client imports (§11 Q7).
60pub const CLIENT_PACKAGE: &str = "@bynk/wire-client";
61
62/// The type names the client's own surface declares. A boundary type of the
63/// same name is renamed in the client (`Client` → `Client_`), never the
64/// surface, so the surface reads the same for every context.
65const SURFACE_TYPES: &[&str] = &[
66    "Admits",
67    "Client",
68    "ConnectOptions",
69    "Env",
70    "ForeignCaller",
71    "Services",
72];
73
74/// The names `@bynk/wire-client` exports that generated code may use, each
75/// with the import specifier that brings it in. The import line carries those
76/// the module actually names ([`bynk_ts::unbound_names`]), so a caller whose
77/// `tsconfig` sets `noUnusedLocals` builds it too.
78const PACKAGE_NAMES: &[(&str, &str)] = &[
79    ("Err", "Err"),
80    ("None", "None"),
81    ("Ok", "Ok"),
82    ("Option", "type Option"),
83    ("Result", "type Result"),
84    ("Some", "Some"),
85    ("ValidationError", "type ValidationError"),
86    ("__BoundaryError", "type __BoundaryError"),
87    ("__JsonValue", "type __JsonValue"),
88    ("__ServiceBinding", "type __ServiceBinding"),
89    ("__WireVersion", "type WireVersion as __WireVersion"),
90    ("__boundaryError", "__boundaryError"),
91    ("__bynkBytesFromBase64", "__bynkBytesFromBase64"),
92    ("__bynkBytesToBase64", "__bynkBytesToBase64"),
93    ("__callService", "__callService"),
94];
95
96/// One service the client exposes.
97struct ClientService {
98    svc: CrossContextService,
99    /// The foreign actors its handler admits, sorted.
100    admits: Vec<String>,
101    hash: String,
102}
103
104/// The contract hash each declared foreign caller may call each service at:
105/// actor → service → hash. What a foreign caller's generated client embeds,
106/// so what `bynk deploy` compares a pin against. Empty when the context
107/// admits no foreign caller.
108pub fn foreign_contracts(
109    table: &UnitTable,
110    own_types: &HashMap<String, Arc<TypeDecl>>,
111) -> BTreeMap<String, BTreeMap<String, String>> {
112    let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
113    for s in client_services(table, own_types) {
114        for actor in &s.admits {
115            out.entry(actor.clone())
116                .or_default()
117                .insert(s.svc.name.clone(), s.hash.clone());
118        }
119    }
120    out
121}
122
123/// The `on call` services a foreign caller may call, sorted by name.
124fn client_services(
125    table: &UnitTable,
126    own_types: &HashMap<String, Arc<TypeDecl>>,
127) -> Vec<ClientService> {
128    let mut names: Vec<&String> = table.services.keys().collect();
129    names.sort();
130    let mut out = Vec::new();
131    for name in names {
132        let sdecl = &table.services[name];
133        let Some(svc) = cross_context_service_for(name, sdecl) else {
134            continue;
135        };
136        let mut admits: Vec<String> = sdecl
137            .handlers
138            .iter()
139            .find_map(|h| bynk_check::actors::call_admission_for(h, &table.actors))
140            .map(|a| a.foreign.into_iter().map(|f| f.actor).collect())
141            .unwrap_or_default();
142        if admits.is_empty() {
143            continue;
144        }
145        admits.sort();
146        admits.dedup();
147        let hash = bynk_check::contract::service_contract_hash(&svc, own_types);
148        out.push(ClientService { svc, admits, hash });
149    }
150    out
151}
152
153/// The client for `ctx_name`, or `None` when no foreign caller may call any of
154/// its services. `own_types` is the context's own type namespace
155/// (`combined_types_for`), the table its contract hashes are computed in.
156pub(crate) fn emit_client(
157    ctx_name: &str,
158    table: &UnitTable,
159    own_types: &HashMap<String, Arc<TypeDecl>>,
160) -> Option<TsProgram> {
161    let services = client_services(table, own_types);
162    if services.is_empty() {
163        return None;
164    }
165    let actors: BTreeSet<&String> = services.iter().flat_map(|s| s.admits.iter()).collect();
166
167    // The codec closure of every exposed signature, in the callee's own
168    // namespace: the same roots a Bynk consumer collects for the services it
169    // calls (`emit_consumed_context_helpers`).
170    let mut roots: Vec<TypeRef> = Vec::new();
171    for s in &services {
172        roots.extend(s.svc.params.iter().map(|(_, t)| t.clone()));
173        roots.push(s.svc.return_type.clone());
174    }
175    let (closure, insts) = serialisation::collect_codec_closure(&roots, own_types);
176    let mut generic_bases: BTreeSet<String> = BTreeSet::new();
177    for i in &insts {
178        if let serialisation::GenericInst::RecordInst { name, .. }
179        | serialisation::GenericInst::SumInst { name, .. } = i
180        {
181            generic_bases.insert(name.clone());
182        }
183    }
184
185    // A boundary type named like the client's surface is renamed here, in a
186    // copy of the table, roots and closure; its contract hash was computed
187    // above from the original, as the callee computes it.
188    let mut declared: BTreeSet<String> = closure.iter().cloned().collect();
189    declared.extend(generic_bases.iter().cloned());
190    let mut subst: HashMap<String, String> = HashMap::new();
191    for name in &declared {
192        if SURFACE_TYPES.contains(&name.as_str()) {
193            let mut to = name.clone();
194            while SURFACE_TYPES.contains(&to.as_str()) || declared.contains(&to) {
195                to.push('_');
196            }
197            subst.insert(name.clone(), to);
198        }
199    }
200    let renamed = |t: &TypeRef| serialisation::rename_type_ref(t, &subst);
201    let types: HashMap<String, Arc<TypeDecl>> = if subst.is_empty() {
202        own_types.clone()
203    } else {
204        own_types
205            .iter()
206            .map(|(k, d)| {
207                let decl = super::rename_in_decl(d, &subst);
208                (
209                    subst.get(k).cloned().unwrap_or_else(|| k.clone()),
210                    Arc::new(decl),
211                )
212            })
213            .collect()
214    };
215    let roots: Vec<TypeRef> = roots.iter().map(renamed).collect();
216    let (names, insts) = serialisation::collect_codec_closure(&roots, &types);
217    let generic_bases: BTreeSet<String> = generic_bases
218        .into_iter()
219        .map(|n| subst.get(&n).cloned().unwrap_or(n))
220        .collect();
221
222    // Every boundary type is declared in this module, under its own name, so
223    // a whole-name qualifier (#1846's form) names it bare while still
224    // selecting the consumer's codec: a transparent refinement re-checked
225    // inline, an opaque one cast, since the owner's `.of` is not here.
226    let mut qual: HashMap<String, String> = HashMap::new();
227    for n in names.iter().chain(generic_bases.iter()) {
228        qual.insert(n.clone(), n.clone());
229    }
230
231    let ru = RuntimeUse::default();
232    let mut body: Vec<TsStmt> = Vec::new();
233
234    // Constants.
235    body.extend(doc_const(
236        "The context this client calls.",
237        "CONTEXT",
238        None,
239        str_lit(ctx_name),
240    ));
241    body.extend(doc_const(
242        "The wire format version this client was generated for. Typed by the\n`@bynk/wire-client` package's own version, so a package that speaks another\nwire version fails `tsc` here.",
243        "WIRE_VERSION",
244        Some(TsType::named("__WireVersion")),
245        str_lit(&bynk_check::wire_format::WIRE_VERSION.to_string()),
246    ));
247    let secret = bynk_check::wire_format::edge_key_to(ctx_name).unwrap_or_else(|| {
248        panic!("bynk internal error: `{ctx_name}` is not an edge name, so it has no call key")
249    });
250    body.extend(doc_const(
251        "The secret this Worker signs its calls with, as `bynk deploy` hands it over.",
252        "SECRET",
253        None,
254        str_lit(&secret),
255    ));
256    let contract_entries: Vec<(String, TsExpr)> = services
257        .iter()
258        .map(|s| (s.svc.name.clone(), str_lit(&s.hash)))
259        .collect();
260    body.extend(doc_const(
261        &contracts_doc(&services, ctx_name),
262        "CONTRACTS",
263        None,
264        TsExpr::As {
265            expr: Box::new(TsExpr::multiline_object(contract_entries)),
266            ty: TsType::named("const"),
267        },
268    ));
269
270    // The surface types.
271    body.extend(doc(
272        "The foreign callers the context declares, by actor name.",
273        TsDecl::TypeAlias {
274            name: "ForeignCaller".to_string(),
275            type_params: Vec::new(),
276            ty: TsType::union(actors.iter().map(|a| TsType::named(quoted(a))).collect()),
277        },
278    ));
279    body.extend(doc(
280        "What the calling Worker's `env` must hold.",
281        TsDecl::Interface {
282            name: "Env".to_string(),
283            type_params: Vec::new(),
284            members: vec![TsTypeMember::readonly_prop(
285                secret.clone(),
286                TsType::named("string"),
287            )],
288        },
289    ));
290    let mut service_members = Vec::new();
291    for s in &services {
292        service_members.push(TsTypeMember::method(
293            s.svc.name.clone(),
294            service_params(&s.svc, &renamed),
295            service_return(&s.svc, &renamed),
296        ));
297    }
298    body.extend(doc(
299        "Every service a foreign caller of the context may call.",
300        TsDecl::Interface {
301            name: "Services".to_string(),
302            type_params: Vec::new(),
303            members: service_members,
304        },
305    ));
306    body.extend(doc(
307        "The foreign callers each service admits.",
308        TsDecl::Interface {
309            name: "Admits".to_string(),
310            type_params: Vec::new(),
311            members: services
312                .iter()
313                .map(|s| {
314                    TsTypeMember::readonly_prop(
315                        s.svc.name.clone(),
316                        TsType::union(s.admits.iter().map(|a| TsType::named(quoted(a))).collect()),
317                    )
318                })
319                .collect(),
320        },
321    ));
322    body.extend(doc(
323        "The services `A` may call: calling one its handler does not admit is a\n`tsc` error rather than a `403`.",
324        TsDecl::TypeAlias {
325            name: "Client".to_string(),
326            type_params: vec!["A extends ForeignCaller".to_string()],
327            ty: TsType::named(
328                "{ readonly [S in keyof Services as [A] extends [Admits[S]] ? S : never]: Services[S] }",
329            ),
330        },
331    ));
332    body.extend(doc(
333        "How to reach the context: the actor this Worker is declared as, its Service\nBinding to the context's Worker, and its `env`, which holds `SECRET`.",
334        TsDecl::Interface {
335            name: "ConnectOptions".to_string(),
336            type_params: vec!["A extends ForeignCaller".to_string()],
337            members: vec![
338                TsTypeMember::readonly_prop("caller", TsType::named("A")),
339                TsTypeMember::readonly_prop("binding", TsType::named("__ServiceBinding")),
340                TsTypeMember::readonly_prop("env", TsType::named("Env")),
341            ],
342        },
343    ));
344
345    // `connect`.
346    let opt = |p: &str| TsExpr::Member {
347        object: Box::new(TsExpr::Ident("options".to_string())),
348        property: p.to_string(),
349    };
350    let mut methods = Vec::new();
351    for s in &services {
352        let encoded: Vec<TsExpr> = s
353            .svc
354            .params
355            .iter()
356            .map(|(name, t)| {
357                serialisation::serialise_expr_via(&renamed(t), &super::ts_ident(name), "", &ru)
358            })
359            .collect();
360        let args = match encoded.len() {
361            0 => TsExpr::object_entries(Vec::new()),
362            1 => encoded.into_iter().next().expect("one argument"),
363            _ => TsExpr::object_entries(
364                s.svc
365                    .params
366                    .iter()
367                    .zip(encoded)
368                    .map(|((name, _), e)| TsObjectEntry::Prop(name.clone(), e))
369                    .collect(),
370            ),
371        };
372        let call = TsExpr::Call {
373            callee: Box::new(TsExpr::Ident("__callService".to_string())),
374            args: vec![
375                opt("binding"),
376                str_lit(&s.svc.name),
377                args,
378                serialisation::deserialise_ref_via(&renamed(&s.svc.return_type), "", &ru),
379                opt("caller"),
380                TsExpr::Member {
381                    object: Box::new(TsExpr::Ident("CONTRACTS".to_string())),
382                    property: s.svc.name.clone(),
383                },
384                opt("env"),
385                TsExpr::Ident("CONTEXT".to_string()),
386            ],
387        };
388        methods.push(TsObjectEntry::Method {
389            name: s.svc.name.clone(),
390            is_async: false,
391            generics: Vec::new(),
392            params: service_params(&s.svc, &renamed),
393            return_type: Some(service_return(&s.svc, &renamed)),
394            doc: None,
395            inline: false,
396            body: vec![TsStmt::return_stmt(Some(call), None)],
397        });
398    }
399    body.extend(doc(
400        "A client calling the context as the foreign caller `options.caller`. Each\ncall is signed under `SECRET`, stamped with its service's contract hash,\nand decoded with the codec a Bynk caller uses. A refusal or transport failure\nis thrown; `callErrorOf` (from `@bynk/wire-client`) reads it.\n\n    const client = connect({ caller: \"<Actor>\", binding: env.<BINDING>, env });",
401        TsDecl::Function {
402            name: "connect".to_string(),
403            generics: vec!["A extends ForeignCaller".to_string()],
404            params: vec![TsParam {
405                name: "options".to_string(),
406                ty: Some(TsType::named_with_args(
407                    "ConnectOptions",
408                    vec![TsType::named("A")],
409                )),
410                optional: false,
411            }],
412            return_type: Some(TsType::named_with_args("Client", vec![TsType::named("A")])),
413            body: vec![
414                TsStmt::const_stmt(
415                    bynk_ts::TsBindingName::Ident("services".to_string()),
416                    Some(TsType::named("Services")),
417                    TsExpr::multiline_object_entries(methods),
418                    None,
419                ),
420                TsStmt::return_stmt(
421                    Some(TsExpr::As {
422                        expr: Box::new(TsExpr::As {
423                            expr: Box::new(TsExpr::Ident("services".to_string())),
424                            ty: TsType::named("unknown"),
425                        }),
426                        ty: TsType::named_with_args("Client", vec![TsType::named("A")]),
427                    }),
428                    None,
429                ),
430            ],
431            is_async: false,
432            inline: false,
433        },
434    ));
435
436    // The boundary types, then their codecs.
437    let mut type_names: Vec<String> = names
438        .iter()
439        .filter(|n| types.get(*n).is_some_and(|d| d.type_params.is_empty()))
440        .cloned()
441        .collect();
442    type_names.extend(generic_bases.iter().cloned());
443    type_names.sort();
444    type_names.dedup();
445    for name in &type_names {
446        if let Some(decl) = types.get(name) {
447            body.extend(boundary_type(name, decl));
448        }
449    }
450    body.extend(serialisation::decls_as_stmts(
451        serialisation::emit_helpers_for_owner_qualified(&names, &types, ctx_name, &qual, &ru),
452    ));
453    body.extend(serialisation::decls_as_stmts(
454        serialisation::emit_generic_helpers_qualified(&insts, &types, &qual, &ru),
455    ));
456
457    // The header and the import line, carrying the package names the module
458    // actually uses.
459    let unbound = bynk_ts::unbound_names(&TsProgram {
460        stmts: body.clone(),
461    });
462    let imports: Vec<String> = PACKAGE_NAMES
463        .iter()
464        .filter(|(name, _)| unbound.iter().any(|u| u == name))
465        .map(|(_, spec)| spec.to_string())
466        .collect();
467    let mut stmts = vec![
468        TsStmt::comment("Generated by bynkc — do not edit by hand.", None),
469        TsStmt::comment(
470            [
471                "The typed client for context `",
472                ctx_name,
473                "`, for a Worker that is not Bynk.",
474            ]
475            .concat(),
476            None,
477        ),
478        TsStmt::comment(
479            "It imports `@bynk/wire-client`: https://bynk-lang.org/book/reference/typescript-client/",
480            None,
481        ),
482        TsStmt::decl(
483            TsDecl::Import {
484                type_only: false,
485                names: imports,
486                from: CLIENT_PACKAGE.to_string(),
487            },
488            None,
489        ),
490    ];
491    stmts.extend(body);
492    Some(TsProgram { stmts })
493}
494
495/// `CONTRACTS`' doc: what the hashes are, and the pin the callee's project
496/// records for each foreign caller (`bynk.foreign.toml`, §8 D3 layer 2), ready
497/// to paste.
498fn contracts_doc(services: &[ClientService], ctx_name: &str) -> String {
499    let mut lines = vec![
500        "The contract hash of each service, as the callee was built. A call stamps".to_string(),
501        "it, and the callee refuses a different one with `409 ContractMismatch`.".to_string(),
502        String::new(),
503        "The context's project pins them per foreign caller in `bynk.foreign.toml`,".to_string(),
504        "beside its `bynk.toml`, and `bynk deploy` refuses a contract change that".to_string(),
505        "breaks a pin. Regenerate this client, then update the pin:".to_string(),
506    ];
507    let mut by_actor: BTreeMap<&String, Vec<&ClientService>> = BTreeMap::new();
508    for s in services {
509        for a in &s.admits {
510            by_actor.entry(a).or_default().push(s);
511        }
512    }
513    for (actor, svcs) in by_actor {
514        lines.push(String::new());
515        lines.push(["    [\"", ctx_name, "\".", actor, "]"].concat());
516        for s in svcs {
517            lines.push(["    ", &s.svc.name, " = \"", &s.hash, "\""].concat());
518        }
519    }
520    lines.join("\n")
521}
522
523/// A service's parameters, typed for the client.
524fn service_params(
525    svc: &CrossContextService,
526    renamed: &dyn Fn(&TypeRef) -> TypeRef,
527) -> Vec<TsParam> {
528    svc.params
529        .iter()
530        .map(|(name, t)| TsParam {
531            name: super::ts_ident(name),
532            ty: Some(ts_type(&renamed(t))),
533            optional: false,
534        })
535        .collect()
536}
537
538/// A service's result, as the promise a client call returns.
539fn service_return(svc: &CrossContextService, renamed: &dyn Fn(&TypeRef) -> TypeRef) -> TsType {
540    let ret = match renamed(&svc.return_type) {
541        TypeRef::Effect(inner, _) => *inner,
542        other => other,
543    };
544    TsType::named_with_args("globalThis.Promise", vec![ts_type(&ret)])
545}
546
547/// The TypeScript type of a boundary type reference, every name bare.
548fn ts_type(t: &TypeRef) -> TsType {
549    super::ts_type_ref_qualified_multi_ts_type(t, &HashMap::new())
550}
551
552/// A boundary type's declaration in the client: its in-memory shape, which is
553/// what the codecs produce and consume. A record is an interface of readonly
554/// fields; a sum a union discriminated by `tag`; a transparent refined type
555/// its base type (the callee checks the predicate); an opaque type its base
556/// type branded, so a caller passes back a value it was given rather than
557/// making one.
558fn boundary_type(name: &str, decl: &TypeDecl) -> Vec<TsStmt> {
559    let params: Vec<String> = decl
560        .type_params
561        .iter()
562        .map(|p| p.name.name.clone())
563        .collect();
564    let d = match &decl.body {
565        TypeBody::Record(r) => TsDecl::Interface {
566            name: name.to_string(),
567            type_params: params,
568            members: r
569                .fields
570                .iter()
571                .map(|f| TsTypeMember::readonly_prop(f.name.name.clone(), ts_type(&f.type_ref)))
572                .collect(),
573        },
574        TypeBody::Sum(sum) => TsDecl::TypeAlias {
575            name: name.to_string(),
576            type_params: params,
577            ty: TsType::multiline_union(
578                sum.variants
579                    .iter()
580                    .map(|v| {
581                        let mut members = vec![TsTypeMember::readonly_prop(
582                            "tag",
583                            TsType::named(quoted(&v.name.name)),
584                        )];
585                        members.extend(v.payload.iter().map(|f| {
586                            TsTypeMember::readonly_prop(
587                                super::payload_prop(&f.name.name),
588                                ts_type(&f.type_ref),
589                            )
590                        }));
591                        TsType::Object(members)
592                    })
593                    .collect(),
594            ),
595        },
596        TypeBody::Refined {
597            base, base_span, ..
598        } => TsDecl::TypeAlias {
599            name: name.to_string(),
600            type_params: params,
601            ty: ts_type(&TypeRef::Base(*base, *base_span)),
602        },
603        TypeBody::Opaque {
604            base, base_span, ..
605        } => TsDecl::TypeAlias {
606            name: name.to_string(),
607            type_params: params,
608            ty: TsType::intersection(vec![
609                ts_type(&TypeRef::Base(*base, *base_span)),
610                TsType::Object(vec![TsTypeMember::readonly_prop(
611                    "__opaque",
612                    TsType::named(quoted(name)),
613                )]),
614            ]),
615        },
616    };
617    let mut out = Vec::new();
618    if let Some(text) = decl.documentation.as_deref() {
619        out.push(TsStmt::doc_comment(text, None));
620    }
621    let mut stmt = TsStmt::decl(TsDecl::Export(Box::new(d)), None);
622    stmt.no_blank_before = !out.is_empty();
623    out.push(stmt);
624    out
625}
626
627fn str_lit(s: &str) -> TsExpr {
628    TsExpr::Lit(TsLit::Str(s.to_string()))
629}
630
631/// `"name"`, a string literal type. Actor, variant and type names are
632/// identifiers, so there is nothing to escape.
633fn quoted(name: &str) -> String {
634    ["\"", name, "\""].concat()
635}
636
637/// An exported declaration under a doc comment.
638fn doc(text: &str, decl: TsDecl) -> Vec<TsStmt> {
639    let mut stmt = TsStmt::decl(TsDecl::Export(Box::new(decl)), None);
640    stmt.no_blank_before = true;
641    vec![TsStmt::doc_comment(text, None), stmt]
642}
643
644fn doc_const(text: &str, name: &str, ty: Option<TsType>, init: TsExpr) -> Vec<TsStmt> {
645    doc(
646        text,
647        TsDecl::ConstDecl {
648            name: name.to_string(),
649            ty,
650            init,
651        },
652    )
653}