Advanced Typescript: dynamic return types without using generics

johncarroll

John Carroll

Posted on October 15, 2019

Advanced Typescript: dynamic return types without using generics

This post is aimed at library authors

While creating the rSchedule recurring date library, I ran into a problem. The library facilitates processing recurring events (e.g. "every Tuesday starting on 2019/10/10") and I created it to be date-library agnostic. Depending on what date library you are using, rSchedule's objects should return dates using the user's chosen date library. But how to accomplish this while maintaining proper typing?

For example, if you use the MomentDateAdapter with rSchedule, rSchedule objects should return Moment dates. The obvious way to do this is via generics. This quickly becomes annoying though, as every rSchedule type in a given application will be providing the same generic argument, over an over again. Want to create a new recurrence Rule? You'll need to do so via Rule<MomentDateAdapter>, etc. I wondered if there was a better way...

A much better approach would be if typescript realized you imported a particular date adapter, and then updated all objects to return the appropriate dates. Well, it turns out you can do this.

For example, if you use rSchedule after importing the MomentDateAdapter (once), then rule.occurrences() is typed as returning MomentDateAdapter objects for the entire application. However, if you were to use rSchedule after importing the LuxonDateAdapter, then rule.occurrences() is typed as returning luxon DateTime objects.

import '@rschedule/moment-date-adapter/setup'
import { Rule } from '@rschedule/core/generators';

const rule = new Rule();

const dates = rule.occurrences().toArray().map(({date}) => date);

// dates is typed as `Moment[]`
Enter fullscreen mode Exit fullscreen mode

vs

import '@rschedule/luxon-date-adapter/setup'
import { Rule } from '@rschedule/core/generators';

const rule = new Rule();

const dates = rule.occurrences().toArray().map(({date}) => date);

// dates is typed as `DateTime[]`
Enter fullscreen mode Exit fullscreen mode

The trick comes from the ability to turn a typescript union into a typescript intersection, combined with typescript declaration merging.

For example, given the following interface:

interface MyCustomDateTypeInterface {
  std: object;
  moment: Moment;
}
Enter fullscreen mode Exit fullscreen mode

We can produce the type object & Moment via

// taken from https://stackoverflow.com/a/50375286/5490505
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void)
    ? I
    : never;

type MyCustomDateType = UnionToIntersection<MyCustomDateTypeInterface[keyof MyCustomDateTypeInterface]> // equals `object & Moment`
Enter fullscreen mode Exit fullscreen mode

From here, we can update MyCustomDateType using declaration merging to add key: value entries to MyCustomDateTypeInterface.

For example, if MyCustomDateTypeInterface started like this:

interface MyCustomDateTypeInterface {
  std: object;
}
Enter fullscreen mode Exit fullscreen mode

We could update it to include moment: Moment by declaring

interface MyCustomDateTypeInterface {
  moment: Moment;
}
Enter fullscreen mode Exit fullscreen mode

At that point, MyCustomDateType would update from being object to object & Moment! This is the black magic rSchedule uses to achieve generic status without using generics!

💖 💪 🙅 🚩
johncarroll
John Carroll

Posted on October 15, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related