As you may or may not know, I am working on preparing to release the v1.0 version for @vue/composition-api recently. One of the current problems is that the type inference does not play well #338. So I get a chance to have a deeper look at vue-next’s type implementations. I will tell you what I learned and how magic works in Vue.
Forget about the setup()
function and Composition API
for now, let talk about the options API in Vue 2 that everybody familiar with. In a classical example, we would have data
, computed
, methods
and some other fields like this:
export default {
data: {
first_name: 'Jarvis',
last_name: 'Mercer',
},
computed: {
full_name() {
return `${this.first_name} ${this.last_name}`
},
},
methods: {
hi() {
alert(this.full_name)
}
}
}
It works well in JavaScript and putting all the context into this
is pretty straightforward and easy to understand. But when you switch to TypeScript for static type checking. this
will not be the context you expected. How can we make the types work for Vue like the example above?
Type for this
#
To explicitly assign the type to this
, we can simply use the this parameter
:
interface Context {
$injected: string
}
function bar(this: Context, a: number) {
this.$injected // ok
}
The limitation of this approach is that we will lose the signature of the method when working with a dict of methods:
type Methods = Record<string, (this: Context, ...args: any[]) => any>
const methods: Methods = {
bar(a: number) {
this.$injected // ok
}
}
methods.bar('foo', 'bar') // no error, the type of arguments becomes `any[]`
We would not want to ask users to explicitly type this
in every method in order to make the type checking works. So we will need another approach.
ThisType
#
After digging into Vue’s code, I found an interesting TypeScirpt utility ThisType
. The official doc says:
This utility does not return a transformed type. Instead, it serves as a marker for a contextual
this
type.
ThisType
would affect all the nested functions. With it, we can have:
interface Methods {
double: (a: number) => number
deep: {
nested: {
half: (a: number) => number
}
}
}
const methods: Methods & ThisType<Methods & Context> = {
double(a: number) {
this.$injected // ok
return a * 2
},
deep: {
nested: {
half(a: number) {
this.$injected // ok
return a / 2
}
}
}
}
methods.double(2) // ok
methods.double('foo') // error
methods.deep.nested.half(4) // ok
The typing works well, but it still requires users to define the type interface of Methods first. Can we make it infer itself automatically?
We can do that with function inference:
type Options<T> = {
methods?: T
} & ThisType<T & Context>
function define<T>(options: Options<T>) {
return options
}
define({
methods: {
foo() {
this.$injected // ok
},
},
})
There is only one step left, to make context object dynamic inference from data
and computed
.
The full working demo would be:
/* ---- Type ---- */
export type ExtractComputedReturns<T extends any> = {
[key in keyof T]: T[key] extends (...args: any[]) => infer TReturn
? TReturn
: never
}
type Options<D = {}, C = {}, M = {}> = {
data: () => D
computed: C
methods: M
mounted: () => void
// and other options
}
& ThisType<D & M & ExtractComputedReturns<C>> // merge them together
function define<D, C, M>(options: Options<D, C, M>) {}
/* ---- Usage ---- */
define({
data() {
return {
first_name: 'Jarvis',
last_name: 'Mercer',
}
},
computed: {
fullname() {
return `${this.first_name} ${this.last_name}`
},
},
methods: {
notify(msg: string) {
alert(msg)
}
},
mounted() {
this.notify(this.fullname)
},
})