Introduction
So you have probably heard of TypeScript and the awesome features it brings, such as interfaces, variable typing and more. Maybe you have even heard of generics, and more advanced types, such as the Index Types and Mapped Types. In this blog I want to show you how advanced types helped us get better autocompletion in more complex situations.
About generics
So if you have heard of generics, you probably know that they abstract types in order to create reusable type definitions. You can even say that they are sort of Type variables. A simple example is an identity function, that works for all types:
function identity<T>(arg: T): T {
return arg;
}
Although not very complex, this function works for every type and ensures that whatever type we pass as input, is the return type of this function. Many other TypeScript Utility Types, such as ReturnType
are generic. ReturnType
for instance is able to extract the return type of any function, whatever types used. As you might see, generics can really improve type declarations, as sometimes you don’t know the type of a function or variable in a declaration. By using generics we can extract the type and provide better typing when we pass it along.
Why we need generics
Since we did not write all of our code in TypeScript, and we like to have fully typed autocompletion even for the legacy parts of our codebase written in plain Javascript, we write declaration modules. In these modules we try to get as good autocompletions as possible and help our developers write better code, with generics to infer types. When writing such a declaration module for our component builder I stumbled upon the problem that generics don’t really work well with plain objects. Our component builder builds document fragments to insert into the DOM, and its input is an object (a component). Each component has at least a create
method, and additional methods to tell the builder what should the input for the create
method should be. These definitions are usually written in the form of {"<expected input data>": null}
The builder will then inject the appropriate data into the component’s create
method. The type of the data is always some data object, which we can simplify for this example to be a TypeScript basic type such as number
. For example, we can define definitions to let the builder know what data we need to have injected for the component to work, such as getDataDefinition
. The type of our argument in the create
method is a mapped type of the return type of the getDataDefinition
method, where each ‘key’ of the returned object is mapped to a number
. The type declaration would then look like this:
type MappedDataDefinition<X> = {
[key in keyof X]: number
}
type Component<X> = {
create: (x: MappedDataDefinition<X>) => string;
getDataDefinition: () => X;
}
Type inference with generics for objects
While in our declaration file we can declare a Component
type, the component is still written in plain Javascript. But if the type of x
can be inferred from getDataDefinition
, we don’t even need to rewrite the component in TypeScript. However, inferring the type from getDataDefinition
is where it becomes tricky, and to illustrate the problem, I have created a simple example:
const myComponent = {
create: (x) => {
// do something with x
return "some result";
},
getDataDefinition: () => ({
a: null,
b: null,
}),
};
When we look at myComponent
, we see it conforms to the type of Component
, and we know that the type of variable x
of the create
function conforms to the type MappedDataDefinition
, inferred from getDataDefinition
. But without specifying that myComponent
conforms to the Component
type, TypeScript does not know that x
has type MappedDataDefinition<X>
. Thus, calling myComponent.create("foo")
will not result in any errors! This is known behaviour, as it is not (yet) possible to infer what type an object is without specifying its type manually. Doing that results in the following:
type MyComponentInput = {
a: null;
b: null;
};
const myComponent: Component<MyComponentInput> = {
create: (x) => {
// do something with x
return "some result";
},
getDataDefinition: () => ({
a: null,
b: null,
}),
};
TypeScript now understands that x
is of type MyComponentInput
, but this would always require us to specify the type of a component, even though we already (although implicitly) defined it in getDataDefinition
, which means we would have our developer create a type for every component. A better way to do this, is using TypeScript’s type inference to automatically infer the type of X
by using a function:
function createComponent<X>(arg: Component<X>): Component<X> {
return arg;
}
const myComponent = createComponent({
create: (x) => "result",
getDataDefinition: () => ({
a: 1,
b: 2,
}),
});
Now we can call myComponent.create
and TypeScript knows we want something of type { a: number; b: number }
as input! Even though createComponentFactory
is just a simple identity function, it allows us to infer the type because it is a function.
Conclusion
Now we have an elegant way to infer the type of a component’s input, without even having to write a type explicitly, just by using a function that returns its argument. Even without even rewriting the component to typescript! Now all our developers have to do to get correct typing, is to surround the component object with a createComponent
function.
If you are interested in the discussion of making it possible to infer types from objects, take a look at this issue on github