loading...

Advanced Typescript: dynamic return types without using generics

johncarroll profile image John Carroll ・2 min read

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[]`

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[]`

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;
}

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`

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;
}

We could update it to include moment: Moment by declaring

interface MyCustomDateTypeInterface {
  moment: Moment;
}

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!

Posted on by:

Discussion

pic
Editor guide