Chainable Options
Challenge
Chainable options are commonly used in JavaScript. But when we switch to TypeScript, can you properly type it?
In this challenge, you need to type an object or a class - whatever you like -
to provide two functions option(key, value)
and get()
. In
option(key, value)
, you can extend the current config type by the given key
and value. We should about to access the final result via get()
.
For example:
declare const config: Chainable;
const result = config
.option("foo", 123)
.option("name", "type-challenges")
.option("bar", { value: "Hello World" })
.get();
// expect the type of result to be:
interface Result {
foo: number;
name: string;
bar: {
value: string;
};
}
You don’t need to write any JS/TS logic to handle the problem - just in type level.
You can assume that key
only accept string
and the value
can be anything -
just leave it as-is. Same key
won’t be passed twice.
Solution
That’s a really interesting challenge with a practical usage in a real world. Personally, I’ve used it a lot when implementing different Builder patterns.
What does author ask us to do? We need to implement two methods
option(key, value)
and get()
. Every next call of the option(key, value)
must accumulate type information about key
and value
somewhere. Accumulation
must proceed until the method get()
was called that returns an accumulated
type information as an object type.
Let us start with the interface author provides to us:
type Chainable = {
option(key: string, value: any): any;
get(): any;
};
Before we can start accumulating the type information, it would be great to get
it first. So we replace string
in key
and any
in value
parameters with
type parameters, so TypeScript could infer their types and assign it to type
parameters:
type Chainable = {
option<K, V>(key: K, value: V): any;
get(): any;
};
Good! We have a type information about key
and value
now. TypeScript will
infer the key
as a string literal type, while the value
as the common type.
E.g. calling option(‘foo’, 123)
will result into having types for
key = ‘foo’
and value = number
.
We have the information, but where can we store it? It must be the place that
persists its state across different method calls. The only place here is on the
type Chainable
itself!
Let us add a new type parameter O
to the Chainable
type and do not forget
that it is by default an empty object:
type Chainable<O = {}> = {
option<K, V>(key: K, value: V): any;
get(): any;
};
The most interesting part now, pay attention! We want option(key, value)
to
return Chainable
type itself (we want to have a possibility to chain the
calls, right) but with the type information accumulated to its type parameter.
Let us use
intersection types
to add new types into accumulator:
type Chainable<O = {}> = {
option<K, V>(key: K, value: V): Chainable<O & { [P in K]: V }>;
get(): any;
};
Small things left. We are getting the compilation error “Type ‘K’ is not
assignable to type ‘string | number | symbol’.“. That’s because we don’t have a
constraint over type parameter K
that says it must be a string
:
type Chainable<O = {}> = {
option<K extends string, V>(key: K, value: V): Chainable<O & { [P in K]: V }>;
get(): any;
};
Everything is ready to rock! Now, when the developer will call the get()
method, it must return the type parameter O
from Chainable
that has an
accumulated type information from previous option(key, value)
calls:
type Chainable<O = {}> = {
option<K extends string, V>(key: K, value: V): Chainable<O & { [P in K]: V }>;
get(): O;
};
Comments