Chainable options type in TypeScript
1interface Chainable {2 option(key: string, value: any): any; // implementation3 get(): any;4}56declare const chainable: Chainable;78const step1 = chainable.option("title", "Mapped Types in functions");9// ^? { title: string }1011const step2 = step1.option("author", { name: "Alexey" });12// ^? { title: string; author: { name: string } }1314const 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
:
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:
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:
1interface Chainable<ParsedConfig = {}> {2 option<Key, Value>(3 key: Key,4 value: Value5 ): 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
:
1interface Chainable<ParsedConfig = {}> {2 option<Key extends string, Value>(3 key: Key,4 value: Value5 ): 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:
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 &
:
1type AddOption<ParsedConfig, Key extends string, Value> = {2 [K in keyof ParsedConfig | Key]: K extends keyof ParsedConfig3 ? ParsedConfig[K]4 : Value;5};67interface Chainable<ParsedConfig = {}> {8 option<Key extends string, Value>(9 key: Key,10 value: Value11 ): Chainable<AddOption<ParsedConfig, Key, Value>>;12 get(): ParsedConfig;13}
Now if we hover over Test
again, we will see:
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:
1import * as yargs from "yargs";23const buildTestCommand = (argv: yargs.Argv) =>4 argv5 .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 });1718yargs.command(19 "test",20 "Running one or more tests",21 (argv) => void buildTestCommand(argv),22 handleTestCommand23);
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:
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;78type TestCommandConfig = Partial<9 FilterPromise<ExtractCommandConfig<typeof buildTestCommand>>10>;1112const handleTestCommand = (config: TestCommandConfig): void => {13 const { testDir, ip, testCount } = config;1415 testDir;16 // ^? string | undefined1718 ip;19 // ^? string | undefined2021 testCount;22 // ^? string | undefined2324 unknownOption;25 // ^? unknown26};
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