beraliv

Chainable options type in TypeScript

Example of Chainable Options use
1interface Chainable {
2 option(key: string, value: any): any; // implementation
3 get(): any;
4}
5
6declare const chainable: Chainable;
7
8const step1 = chainable.option("title", "Mapped Types in functions");
9// ^? { title: string }
10
11const step2 = step1.option("author", { name: "Alexey" });
12// ^? { title: string; author: { name: string } }
13
14const result = step2.get();
15// ^? { title: string; author: { name: string } }

Today we discuss Chainable Options

That's one of the most popular challenges I've worked with: you need to connect the data type with event type, e.g. for your tracking or logging.

But the difference here is that you need to infer type from the calls.

Collect types from calls

First we can do is to return Chainable for a function option:

Change ReturnType for option function
1interface Chainable {
2 option(key: string, value: any): Chainable;
3 get(): any;
4}

Next, we say that we start from an empty object type and call after call we collect the data type in it. Let's add ParsedConfig as Generic:

Added generic type T
1interface Chainable<ParsedConfig = {}> {
2 option(key: string, value: any): Chainable;
3 get(): ParsedConfig;
4}

Last but not least is the collection itself. Let's add types for a key and a value as Generic:

Add key and value for every option call
1interface Chainable<ParsedConfig = {}> {
2 option<Key, Value>(
3 key: Key,
4 value: Value
5 ): Chainable<ParsedConfig & Record<Key, Value>>;
6 get(): ParsedConfig;
7}

If you check temporary solution in Playground (https://tsplay.dev/wOGbrw), you will see that Type 'Key' does not satisfy the constraint 'string | number | symbol'

It means we need to apply Generic Constrain for Key, it should be a string:

Solution
1interface Chainable<ParsedConfig = {}> {
2 option<Key extends string, Value>(
3 key: Key,
4 value: Value
5 ): Chainable<ParsedConfig & Record<Key, Value>>;
6 get(): ParsedConfig;
7}

That's it 💪

Don't forget to check the solution in Playground – https://tsplay.dev/mLqMAW

Improve readability

If you create type Test = typeof result and hover over Test, you will see this:

Inferred type for result
1const result: Record<"foo", number> &
2 Record<
3 "bar",
4 {
5 value: string;
6 }
7 > &
8 Record<"name", string>;

That happens because we use Intersection types, or in other words &.

We can use Mapped types to add key and value without &:

Hack with Flatten type
1type AddOption<ParsedConfig, Key extends string, Value> = {
2 [K in keyof ParsedConfig | Key]: K extends keyof ParsedConfig
3 ? ParsedConfig[K]
4 : Value;
5};
6
7interface Chainable<ParsedConfig = {}> {
8 option<Key extends string, Value>(
9 key: Key,
10 value: Value
11 ): Chainable<AddOption<ParsedConfig, Key, Value>>;
12 get(): ParsedConfig;
13}

Now if we hover over Test again, we will see:

Updated inferred type for result
1type Test = {
2 foo: number;
3 bar: {
4 value: string;
5 };
6 name: string;
7};

We improved the readability of inferred value, you can see the updated Chainable type in Playground – https://tsplay.dev/w1PMGW

Real-life example

You can find chainable options in yargs – the node.js library to create a CLI for your needs.

For example, when you need to create a command test which can accept optional testDir, ip and testCount options, you can define it this way:

Test command which accepts 3 optional options
1import * as yargs from "yargs";
2
3const buildTestCommand = (argv: yargs.Argv) =>
4 argv
5 .option("testDir", {
6 type: "string",
7 description: "A path to a directory containing test files",
8 })
9 .option("ip", {
10 type: "string",
11 description: "IP of device where you want to run tests",
12 })
13 .option("testCount", {
14 type: "number",
15 description: "Number of times you want to run tests",
16 });
17
18yargs.command(
19 "test",
20 "Running one or more tests",
21 (argv) => void buildTestCommand(argv),
22 handleTestCommand
23);

Then we will need to define handleTestCommand where config is inferred from options that we included in buildTestCommand.

How can we do it? I will show the solution for it as is:

Infer command config out of build function
1type AnyFunction = (...args: any[]) => any;
2type ExtractCommandConfig<Fun extends AnyFunction> =
3 ReturnType<Fun> extends yargs.Argv<infer ParsedOptions>
4 ? yargs.Argv<ParsedOptions>["argv"]
5 : never;
6type FilterPromise<Union> = Union extends Promise<any> ? never : Union;
7
8type TestCommandConfig = Partial<
9 FilterPromise<ExtractCommandConfig<typeof buildTestCommand>>
10>;
11
12const handleTestCommand = (config: TestCommandConfig): void => {
13 const { testDir, ip, testCount } = config;
14
15 testDir;
16 // ^? string | undefined
17
18 ip;
19 // ^? string | undefined
20
21 testCount;
22 // ^? string | undefined
23
24 unknownOption;
25 // ^? unknown
26};

The whole yargs example is available here – https://tsplay.dev/wXQJ1N 👏

We correctly inferred string | undefined for known options and unknown for unknown option ✅

Thank you for your time and have a nice day ☀️

typescript
Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter