These instructions assume you already have a Rails 5.2 project using Webpacker 4 with Vue 2 and Vuex 3. I'll show you how to add TypeScript to the project, and type-safe your Vue components, including single-file components (SFCs). This document will not teach you TypeScript syntax or type theory. It also assumes your code already works without TypeScript. You shouldn't use this article to, for example, get started with Vuex, because I'm leaving out lots of necessary boilerplate code and focusing just on TypeScript changes.
If you want to see a commit on a project accomplishing this migration, visit https://github.com/RISCfuture/AvFacts/commit/666a02e58b4626a074a03812ccdd193a3891a954.
-
Run
rails webpacker:install:typescript
. This should modifyconfig/webpacker.yml
andconfig/webpack/environment.js
(leave those changes), addtsconfig.json
andconfig/webpack/loaders/typescript.js
(leave those files), and add some other files inapp/javascript
(delete those files). -
Edit your
tsconfig.json
file. If it's not already, change themodule
setting to"es6"
(this will allow you to use ES6-styleimport
/export
syntax). Add"strict": true
(because you want strict type-safety checking, right?). -
Edit the
config/webpack/loaders/typescript.js
file. The options for thets-loader
loader should bePnpWebpackPlugin.tsLoaderOptions()
. Change this toPnpWebpackPlugin.tsLoaderOptions({appendTsSuffixTo: [/\.vue$/]})
. This will ensure that TypeScript recognizes.vue
files as processable. -
The
test:
regex in this file is wrong too. If you want, you can tighten it up by changing it to/\.(ts|tsx)(\.erb)?$/
. -
Run
yarn add vue-class-component vue-property-decorator vuex-class @types/node
. -
If you have any JS libraries that need type-safety, then Yarn-install their
@types
packages. For example, I ranyarn add @types/lodash @types/moment @types/mapbox-gl
. (Most guides tell you to include these modules asdevDependencies
, but because Webpacker transpiles the code in the production environment, you will need to make them production dependencies.)
Now start adding types to your JavaScript files! The easiest is just your pure .js
files (not Vue components). Simply change the extension to .ts
and begin adding TypeScript syntax. See the TypeScript Handbook to learn more.
If you are importing any Yarn modules for which you added @types
packages, you will need to change the import syntax from, e.g.:
import _ from 'lodash`
to
import * as _ from 'lodash'
You'll probably want to add some kind of types.ts
in your app/javascript
where you can export out all the new application types you'll be adding. Some people like to modify their tsconfig.json
so this types file is added to the global namespace, but I prefer leaving the global namespace unadulterated and importing the types I need into each .ts
file.
If you're using Vue-Router, change your routes.js
to routes.ts
and type your routes object as RouteConfig[]
.
I'm using vue-class-component to rewrite my Vue components as classes using decorators. I'll show you the syntax changes here. These changes work for both SFCs and traditional Vue components using Vue.extend
.
You will first need to change the <script>
tag to <script lang="ts">
.
Change your Vue options object into a TypeScript class. Make your class definition to look like this:
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
}
If your view includes components, add them like so:
import Vue from 'vue'
import MyComponent from './MyComponent.vue'
@Component {(
components: [MyComponent]
)}
export default class MyView extends Vue {
}
Use the mixins
method to add mixins to your class:
import Component, {mixins} from 'vue-class-component'
import MyMixin from 'MyMixin.ts'
@Component
export default class MyView extends mixins(MyMixin) {
}
The mixins
method can any number of mixins.
Change your data into instance properties. If you need to dynamically initialize any data, do it in the mounted()
or created()
hook instead of in the data()
method.
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
counter: number = 0
userName?: string = null
mounted() {
this.userName = getUsername()
}
}
Important note: Your instance properties must be initialized with a default value to convert them to reactive data. Make them optional and initialize them to null
if necessary.
Use the @Prop
decorator to declare your properties. Pass the prop attributes as decorator options:
import Vue from 'vue'
import Component from 'vue-class-component'
import {Prop} from 'vue-property-decorator'
@Component
export default class MyView extends Vue {
@Prop({type: String, required: true}) username!: string
}
Note that unfortunately you have to declare the type twice: Once in the prop attributes (for Vue's type-checking) and once for TypeScript. Also note that for built-ins, Vue requires the boxed class (String
) whereas TypeScript requires the primitive (string
).
Also note that, as of TypeScript 2.7, you need to definitely define the property (hence the bang after the property name).
Change Vue getters into TypeScript getters:
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
get fullName(): string {
return this.firstName + " " + this.lastName
}
}
Feel free to mark these getters as private
or protected
if appropriate.
Make your methods normal instance methods.
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
setFilter(filter: string) {
this.$refs.filter.value = filter
}
}
Also feel free to mark these private
or protected
if appropriate.
Filters are not expressible in the vue-class-component syntactic sugar, and so must be passed as raw options to the Component decorator:
import Vue from 'vue'
import Component from 'vue-class-component'
@Component({
filters: {
formatDate(date: Date) { return moment(date).format('MMM D YYYY') }
}
})
export default class MyView extends Vue {
}
You can still add types to the filter methods though.
Hooks also become normal instance methods.
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
mounted() {
this.loadData()
}
}
Watchers become normal instance methods, annotated with the Watch component. They can be named whatever you'd like, and you can have multiple methods act as responders to a single watcher.
import Vue from 'vue'
import Component from 'vue-class-component'
import {Watch} from 'vue-property-decorator'
@Component
export default class MyView extends Vue {
@Watch('currentSelection')
onCurrentSelectionChanged(before: string, after: string) {
this.loadDataForSelection(after)
}
}
To add type-safety to your refs, explicitly declare the type of the $refs
property:
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
$refs!: {
filterForm: HTMLFormElement
}
}
If you use $el
, $parent
, or $children
, you can type-declare those as well:
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class MyView extends Vue {
$parent!: MyParentView
$children!: MySubview[]
}
If you're using Vue options objects as mixins, you'll want to change those to decorated classes as well. Write them just like you would write a Vue class as described in the previous section.
import Vue from 'vue'
import Component from 'vue-class-component'
@Component
export default class AutoRefresh extends Vue {
refreshTimer?: number
refreshHandler: () => void
mounted() {
this.refreshTimer = window.setInterval(this.refreshInterval, 5000)
}
}
In your root Vuex file (for example, app/javascript/store/index.ts
), define an interface with all your root state properties:
export interface RootState {
authenticated: boolean
}
Change your initialization code to:
new Vuex.Store<RootState>(options)
For each module, define an interface with the properties for that module's state:
export interface PostsState {
posts: Post[]
postsLoaded: boolean
postsError?: Error
}
Add types to your root and module getters as appropriate:
const getters = {
getPosts(state: PostsState) {
return state.posts
}
}
Add types to your root and module mutations as appopriate:
interface AppendPostsPayload {
posts: Post[]
page: number
}
const mutations = {
appendPosts(state: PostsState, {posts, page}: AppendPostsPayload) {
state.posts = state.posts.concat(posts)
}
}
And add types to your root and module actions as appropriate:
import {ActionContext} from 'vuex'
const actions = {
loadPosts({commit, state}: ActionContext<PostsState, RootState>, {force}: {force: boolean}): Promise<boolean> {
if (state.postsLoaded && !force) return Promise.resolve(false)
return new Promise((resolve, reject) => {
commit('startLoading')
// [...]
commit('finishLoading')
resolve(true)
})
}
}
Finally, refine the type of each module that you create:
const posts: Module<PostsState, RootState> = {state, getters, mutations, actions}
export default posts
Use the Getter decorator to map Vuex getters into your Vue components.
import Vue from 'vue'
import Component from 'vue-class-component'
import {Getter} from 'vuex-class'
@Component
export default class PostsIndex extends Vue {
@Getter getPosts!: Post[]
}
Unfortunately, for type-safety, you need to specify the type each time you use the Getter decorator, even though you specified it in the Vuex code.
Use the Action decorator to map Vuex actions into your Vue components.
import Vue from 'vue'
import Component from 'vue-class-component'
import {Action} from 'vuex-class'
@Component
export default class PostsIndex extends Vue {
@Action loadPosts!: ({commit, state}: ActionContext<PostsState, RootState>, {force}: {force: boolean}) => Promise<boolean>
}
Unfortunately, as with Getters, you need to specify the signature each time you use the Action decorator, even though you specified it in the Vuex code.
If you are using import
to bring any images into your JavaScript or Vue components, you will have to modify your code to inform TypeScript how to import those images.
You can add a shim file, say app/javascript/shims.d.ts
that looks like this:
declare module '*.jpg' {
const URL: string
export default URL
}
This tells TypeScript that JPEG files are imported as string URLs by Webpack. Add whatever other image extensions you need. You must add the following to your tsconfig.json
file:
"files": [
"app/frontend/shims.d.ts"
]
Then, where you import those images, handle them like they're strings:
import Logo from 'images/logo.jpg'
@Component
export default class Banner extends Vue {
logoURL = Logo
}
In your render()
method or SFC view, you can then use this.logoURL
to access the URL for the image.
Unfortunately, the .ts.erb
file syntax doesn't seem to be recognized correctly, even after fixing the regular expression in config/webpack/loaders/typescript.js
. The only way I've figured out how to do it is to leave it as a .js.erb
file, and add a TypeScript shim file.
Assume you have a app/javascript/config/secrets.js.erb
file like so:
export default {
mapboxAccessToken: '<%= Rails.application.credentials.mapbox_access_token %>'
}
You can add a shim file (see the previous section on importing images) that looks like this:
declare module 'config/secrets.js' {
interface Secrets {
mapboxAccessToken: string
}
const secrets: Secrets
export default secrets
}
FYI I found this method using webpacker to be easier in getting secrets into TS https://stackoverflow.com/a/65277209/897414