Home Posts Notes About

Template Literal Types for Dynamic API Generation

August 15th, 2025 · #typescript cube-records

Continuing my work on cube-records, I wanted to support joined field names like orders.total or users.email - matching exactly how the underlying Cube names them. Instead of manually defining these combinations, TypeScript's template literal types generate them automatically.

The Pattern

Here's a simplified version of the Cube schema:

// User defines their schema
interface Schema {
  users: {
    fields: { id: string; email: string };
    joins: ['orders'];
  };
  orders: {
    fields: { total: number; status: string };
    joins: [];
  };
}

Then, we use a template literal type to defined our joined fields:

// Template literal magic
type JoinedFields<T extends keyof Schema> =
  Schema[T]['joins'][number] extends infer Join
    ? Join extends keyof Schema
      ? `${Join}.${keyof Schema[Join]['fields'] & string}`
      : never
    : never;

Finally, we can also use the type to create our own custom type.

// TypeScript now knows these fields exist:
type UserJoinedFields = JoinedFields<'users'>;
// Result: 'orders.total' | 'orders.status'

The infer keyword extracts each join name, then template literals compose the dot-notation paths.

Usage

With the schema defined above and our new type, we can define a Query type like the following:

type Query<T extends keyof Schema> = {
  model: T;
  fields: (Schema[T]['fields'] & JoinedFields<T>)[];
}

And in our code, we can declare a query serenely thanks to the type system preventing us from writing a query with invalid fields:

const query: Query = {
  model: 'users',
  fields: [
    'email',          // ✓ users field
    'orders.total',   // ✓ joined field
    'orders.status',  // ✓ joined field
    'invalid.field'   // ✗ Type error
  ]
};

The real implementation has more conditionals for safety, but the core idea remains the same: extract the join names, then use template literals to compose the field paths.

Trade-offs

Benefits:

  • Zero maintenance—add a field to the schema, get autocompletion everywhere
  • Catches typos at compile time instead of runtime
  • Scales to hundreds of field combinations without manual work

Drawbacks:

  • Type computation can slow down in massive schemas
  • Error messages become cryptic when deeply nested
  • Only works with predictable string patterns

Notes

This pattern shines when you control the schema and need to generate predictable string patterns. It's less suitable for user-defined schemas or when you need runtime validation anyway. The sweet spot is internal APIs where you want compiler-enforced consistency between your types and runtime behavior.

What you don't write is as important as what you do. No manual string unions. No keeping field lists in sync. Add a new join relationship, and TypeScript immediately knows about every possible field combination.

Mastodon