Skip to content

Template literal type support — structured string builder generation #2

Description

@guybedford

Currently ts-gen maps TypeScript template literal types to String in Rust, which is correct but loses the structural information encoded in the type. For example, from the Cloudflare Workflows API:

type WorkflowDurationLabel = "second" | "minute" | "hour" | "day" | "week" | "month" | "year";
type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${"s" | ""}` | number;

This is currently emitted as String, but the template literal has enough structure to generate a typed builder.

Setter naming rules

  • Named type references get a setter named after the type in snake_case, taking the generated enum by value: .workflow_duration_label(WorkflowDurationLabel::Second)
  • Primitive types (number, string) and anonymous unions (${"s" | ""}) get positional setters: .set_0(...), .set_1(...), .set_2(...)
  • The first positional slot additionally gets a .set(...) convenience alias for .set_0(...)
  • Anonymous string union slots default to their first value, so ${"s" | ""} defaults to "s" without the user needing to set it. Primitive types do not default.

Constructor and builder

A new() constructor is generated taking all non-defaultable slots positionally, covering the common case concisely. The builder exists for when defaults need to be overridden.

// common case
WorkflowSleepDuration::new(5, WorkflowDurationLabel::Minute)
// => "5 minutes"

// override a default
WorkflowSleepDuration::builder()
    .set(5)
    .workflow_duration_label(WorkflowDurationLabel::Minute)
    .set_2("")  // override the default "s"
    .build()

The literal separator between slots ( in this case) is a fixed string baked into build() and new(). Both return String.

Type evaluation before codegen

Template literals often contain TypeScript utility types in their slots, e.g. Capitalize<T>. Since ts-gen already resolves type references, these should be eagerly evaluated against their concrete instantiations before builder generation — by the time Rust is emitted all slots are concrete string literals or primitives. The complexity stays in the IR resolution phase, not the codegen phase.

General pattern

The structure ${A}${literal}${B}${literal}${C} maps cleanly to a new() constructor and builder with one slot per interpolation regardless of the number of slots or separator literals. This covers a meaningful subset of real-world template literal types in the CF Workers API surface (durations, URLs, event name patterns, etc.), with String remaining the fallback for cases that can't be reduced to this pattern.

Relationship to #1

The generated builder would follow the same builder pattern described in #1 for object types, and could potentially share the same build() optimisation path once that lands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions