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.