Vue 2.5 improves TypeScript definition! Before that, TS users will have to use class component API to get proper typing, but now canonical API is both precise and concise with few compromises!
For ordinary users, Vue’s official blog and updated documentation will guide you to upgrade or create projects.
But curious audience might wonder how the improvement is done and why TS support isn’t integrated in Vue2.0 at first place.
This blog post will deep dive into the technical details of Vue2.5 typing, which seems daunting at first glance. Don’t worry! We will show how TypeScript’s advanced types can be used in a popular framework.
Note: Reader’s familiarity with Vue and TypeScript is assumed in this post. If you are new to these two, checkout their official website!
TL;DR;
Vue2.5 exploits ThisType, mapped type, generic defaults and a clever trick to cover most APIs.
We will also list some limitations in current typing schema.
this
is Vue
Let’s examine a basic Vue usage. We pass an object literal as component option to Vue constructor.
1 | new Vue({ |
this
keyword is bound to Vue instance in component option. Prior to Vue2.5, we declare this
as a plain Vue type. Here is a simplified ComponentOption
type.
1 | interface ComponentOption { |
However, we cannot access our custom methods/data via the declaration above since this
is nothing but Vue. The typing doesn’t capture the fact that the VM injected into methods is instantiated with our custom methods/data/props.
A new type parameter V
can allow users to specify their custom properties. So a better solution will be:
1 | interface ComponentOption<V extends Vue> { |
And users can use it like this.
1 | declare function newVue<V extends Vue>(option: ComponentOption<V>): V |
It works, but also requires one interface declaration and one explicit type annotation.
Can compiler be smarter and infer this for us?
ThisType<Vue>
We can strongly type this
by a special marker interface ThisType
. It is introduced in TypeScript 2.3, which is the very reason why we didn’t have strong type until Vue2.5.
The original pull request has a detailed introduction and example for ThisType
.
The most important rule is quoted here.
(If) the containing object literal has a contextual type that includes a
ThisType<T>
,this
has type T
What does this mean? Let’s break this rule down to several pieces.
object literal
means the component option in Vue’s case; contextual type
means the component option is passed to a function as argument and the component option is typed via function declaration, without caller’s annotation; and finally ThisType<T>
needs to be used in the function parameter declaration. The type parameter T
refers to the type of this
in the component option. In simple terms, this rule says we can change this
keyword’s type according to the component option passed to new Vue
or so.
Combining these together, we can write a simple declaration that understands our Vue component option.
Note, you will need
noImplicitThis
compiler flag to enable this new type checking.
1 | interface ComponentOption<Method> { |
This code needs some explanation. First we define an ComponentOption
and it takes a type parameter Method
, which acts as a “stub” for compiler to infer custom properties on this
.
Then in the function we declare a type parameter Method
again and pass it to ComponentOption
and ThisType
.
Finally, ThisType<Method & Vue>
means the type of this
inside option will be an intersection of Vue
and Method
.
When we call newVue
, compiler will first infer Method
from ComponentOption
object we pass to the function. Then the Method
will flow into this
keyword, resulting a type that has both Vue property and our own methods.
Mapping Computed
Typing methods
alone is so far so good. However fields like computed
have a different story.
The object in methods
field has the same shape as part of this
type. Say, methods
has a hello
function property and this
also has a function property with the same name (in algebraic terms, endomorphism). But a property in computed
is a function that returns a value and this
has a namesake property with the same value type. For example.
1 | newVue({ |
How can we get a new type from computed
definition object?
Here comes the mapped type, a new kind of object type that maps a type representing property names over a property declaration template. In other words, we can create computed type in Vue instance based on that in component option. (algebraically, homomorphism)
In a mapped type, the new type transforms each property in the old type in the same way.
The official documentation is crystal clear. Let’s see how we integrate this awesomeness into Vue.
1 | // we map a plain type to a type of which the property is a function |
Accessors<T>
will map the type T
to a new type with same property names. But property value type is a function returning the type in the original T
. This process is reversed during type inference. When we pass computed
field as {myname: () => string}
to newVue
function, compiler will try to map the type to Accessors<T>
, which results in Computed
being {myname: string}
.
And Computed
is mixed into this
, so we can access myname
as string
from this
.
We skipped here computed setter style declaration for a more lucid demonstration. Supporting setter in computed
is similar.
Prop Types Trick
props
has a subtle difference from computed
: we define a prop
by giving a constructor of that value type.
1 | type PropDef<T> = { new(...args: any[]): T } |
One would naturally expect newVue
will infer Prop
as { user: User, name: string }
. Sadly, it is not.
The problem lies in PropDef
, which uses constructor type new(): T
. Custom constructor is fine. For example User
‘s constructor returns User
. But primitive value’s constructor doesn’t work because String
has the signature new(): String
.
Alas! The return value is String
, rather than string
. Their difference is listed in the first rule of TypeScript Do’s and Don’ts. A string
type is what we use and String
refers to non-primitive boxed objects that are almost never used.
We can use another signature to type primitive constructor and union custom constructor together. Note every primitive constructor has a call signature, that is, String(value)
will return a primitive string
value rather than a wrapper object.
1 | type PropDef<T> = { (): T } | { new(...args: any[]): T } |
It should work, shouldn’t it? Sadly again, NO.
1 | declare function propTest<T>(t: PropDef<T>): T |
Because String
satisfy both call and constructor signature in PropDef
, compiler will prefer returning String
.
How can we nudge compiler to prefer primitive type? Here is an undocumented trick.
The main idea is to exploit type inference priority. If a type parameter is single naked, that is, not in intersection type nor in union type, compiler will prefer to infer from that single naked position over intersection/union position. So we can add an intersection to constructor signature and then compiler will first infer call signature. Exactly what we want! To make the signature more self explanatory, we can use the object
type to flag constructor type should not return primitive type.
1 | type PropDef<T> = { (): T } | { new(...args: any[]): T & object } |
Now we can happily infer props
without manual annotation!
Compatibility
For better inference, our new type has many more type parameters than original ComponentOption<V>
which only has one parameter. Nevertheless, it will be a catastrophic breaking change if we ship the new type without proper fallback. Generic defaults introduced in TS2.3 gives us a chance to bring about a more smooth upgrade.
1 | interface ComponentOption<V extends Vue, Method=any, Data=any, Prop=any, Computed=any> { |
Happy ending!
Limitation
The “No silver bullet” rule also applies to typing. The more advanced types we use, the more complex error messages will be generated. Hope this blog post will help you to understand the new typing better and help you to debug your own application.
There are also some type system limitations in Vue typing. Let’s see some examples.
- functions in
computed
need return type annotation
Return type annotation is required if computed
method uses this
. It turns out that using mapped type and ThisType at the same time without explicit annotation will cause cyclic inference error in current compiler.
1 | new Vue({ |
TypeScript has already opened an issue tracking this.
- Prop types’ union declaration requires manual type cast
Vue accepts an array of constructors in prop’s definition as union type. However, PropDef
cannot unify primitive constructors and custom constructors which have two heterogeneous signatures.
1 | Vue.component('union-prop', { |
In general, you should avoid mixing primitive type and object type.
Final words
TypeScript has been constantly evolving since its birth. And finally its expressiveness enable us to type Vue’s cannonical API!
Thank you, TypeScript team, for bring us these awesome features!
Thank you, Vue team, for embracing new advance in type system!