# Template Literal Types for Dynamic API Generation

til · 2025-08-15 · #cube-records

Continuing my work on [`cube-records`][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:

```typescript
// 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:

```typescript
// 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
// 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:

```typescript
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:

```typescript
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.

[cube-records]: https://gnldxt.link/cube-records
