Skip to content

Instantly share code, notes, and snippets.

@RISCfuture
Last active June 3, 2023 05:48
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save RISCfuture/7ec8d98036577cbb47947f474ed1aae2 to your computer and use it in GitHub Desktop.
Save RISCfuture/7ec8d98036577cbb47947f474ed1aae2 to your computer and use it in GitHub Desktop.
Adding TypeScript to a Rails + Webpacker + Vue project

Adding TypeScript to a Rails + Webpacker + Vue project

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.

Setup

  1. Run rails webpacker:install:typescript. This should modify config/webpacker.yml and config/webpack/environment.js (leave those changes), add tsconfig.json and config/webpack/loaders/typescript.js (leave those files), and add some other files in app/javascript (delete those files).

  2. Edit your tsconfig.json file. If it's not already, change the module setting to "es6" (this will allow you to use ES6-style import/export syntax). Add "strict": true (because you want strict type-safety checking, right?).

  3. Edit the config/webpack/loaders/typescript.js file. The options for the ts-loader loader should be PnpWebpackPlugin.tsLoaderOptions(). Change this to PnpWebpackPlugin.tsLoaderOptions({appendTsSuffixTo: [/\.vue$/]}). This will ensure that TypeScript recognizes .vue files as processable.

  4. The test: regex in this file is wrong too. If you want, you can tighten it up by changing it to /\.(ts|tsx)(\.erb)?$/.

  5. Run yarn add vue-class-component vue-property-decorator vuex-class @types/node.

  6. If you have any JS libraries that need type-safety, then Yarn-install their @types packages. For example, I ran yarn add @types/lodash @types/moment @types/mapbox-gl. (Most guides tell you to include these modules as devDependencies, but because Webpacker transpiles the code in the production environment, you will need to make them production dependencies.)

JavaScript Changes

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[].

Vue Components

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">.

Class definition

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 {
}

Components

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 {
}

Mixins

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.

Data

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.

Props

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).

Getters

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.

Methods

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

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

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

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)
  }
}

Refs, $parent, $children, etc.

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[]
}

Vue Mixins

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)
  }
}

Vuex Modules

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

Mapped Getters

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.

Mapped Actions

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.

Importing images

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.

ERb Files

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
}
@clarkbw
Copy link

clarkbw commented Aug 11, 2021

FYI I found this method using webpacker to be easier in getting secrets into TS https://stackoverflow.com/a/65277209/897414

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment