TypeScript: sharing type definitions seamlessly between server and client (with Vue.js)
TL;DR - Have both your server code and client code within the same directory. Configure webpack's devServer to host your API routes so you also avoid CORS issues during development
You can find the repo for this demo app here
Have you ever created both your SPA and your server in TypeScript, and quickly realized that you needed to share type definitions between the two, but couldn't do it? Yeah, me too. This was most probably because you had two applications: one named "client", and another one named "server". Each directory had its own package.json
and you had to run npm <SOMETHING>
in different consoles to run your client server (development server most probably) and your back-end server. But here is the thing, you could only have a single server that serves both your SPA (when making a HTTP request to /
), and your API routes (when making HTTP requests that start with /api/
). I'm going to show you how to do this with a Vue app, but I don't think it'd much different with a React app (haven't tried though, in case you have and have faced issues, please let me know!).
Whenever you build a Vue app created with the vue-cli
, you are working with a lot of webpack configuration under the hood that vue-cli
handles for you. Luckily enough, you can tweak those settings to your liking creating a file named vue.config.js
and exporting your changes. When you run Vue's development server via npm run serve
, you are relying on webpack-dev-server
, which can be highly customized. What we are going to do is pass a function to webpack to configure the development server, so we can add our API routes to it. This function receives an express
server object for us to configure. After doing this, both our SPA and our API routes will be handled by the same server, and as a side-effect, we don't have to worry about CORS (our front-end app will be making HTTP request to the same origin, so we won't see any such errors in our browser's console)
Scaffolding our app
We are going to create our app using the vue-cli
, which in case you don't have it installed in your computer you can install it by running npm install -g @vue/cli
or yarn global add @vue/cli
. Then, create your Vue app by running vue create .
to create your app in the current working directory (you can specify a name to create a brand new directory as well). Select Manually select features
when asked and then check TypeScript
by hitting the space key while selecting it. Press enter and leave the rest of the options with the default values. Once the vue-cli
finished creating our app, run the dev server by typing npm run serve
in your console.
Okay great, so far we have only created a Vue app with TypeScript support. Next, we are going to create our back-end server. To do so, add express
as a dependency (npm install express
) and its types as a development dependency (npm install --save-dev @types/express
). Create a new directory called server
inside src
(src/server
), and add an index.ts
file inside of it (src/server/index.ts
). We won't add anything special in this folder, we are only going to create and export our function to configure the server
import { Express } from "express";
export function configureServer(app: Express) {
app.get("/api", (_, res) => {
res.json({
message: "Hello world!"
});
});
}
Our function configureServer
receives an express
server object, and adds to it a request handler to response with a JSON object whenever a GET
HTTP request is made to /api
. You could of course connect to a database, read files, and what have you, but for the sake of this article we are going to keep it as simple as possible. If you would like to see a little bit more complex project, you can check this repo of mine where I followed this same structure and connected the server to a Mongo database.
Next thing we need to do is tell webpack somehow we want to use that function to set up the development server. To do so, Vue allows us to create a vue.config.js
file to customize webpack. We now face our first problem, as Vue doesn't allow to create a vue.config.ts
file, so we cannot import our function from a TypeScript file into a JavaScript file without transpiling our TS code first.
To transpile our TypeScript code, we need to run npx tsc src/server/index.ts
, which will create a index.js
file inside src/server
. In case you want to reuse or extend some of your base tsconfig.json
settings (the one located at the same level as your node_modules
, created by default by vue-cli
when you created your Vue app with TypeScript support), you could add a new tsconfig.json
in src/server
with the following content
{
"extends": "../../tsconfig.json"
}
You should now compile your server-side TS code by doing npx tsc -p src/server
.
Now that we have compiled down our TS code to JS, we can import our configureServer
function into vue.config.js
. Create a file named vue.config.js
at the same level as your node_modules
directory and add the following content
const { configureServer } = require("./src/server");
module.exports = {
devServer: {
before: configureServer
}
};
By doing this, we are adding extra functionality to the development server before webpack configures it.
If we now try to run npm run serve
once again, we see a syntax error telling us that about an unexpected token "export". This happens because our src/server/index.js
code does not follow a format that node understands, or to be more precise, our TypeScript code is compiled to use in as a ECMAScript module, which is something node by default does not understand. To sort this out, we need to tweak our src/server/tsconfig.json
file a little bit, changing the compiler options to compile our code to commonjs
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "CommonJS"
}
}
If we now compile once again our server-side code by doing npx tsc -p src/server
, we should successfully run our app by doing npm run serve
. Up to this point, we already have an API endpoint for us to try out. Navigate to localhost:8080/api
, and you should get back a JSON object with a message.
This article is about sharing type definitions, but we haven't done anything of that yet! That's the next thing we are going to do, but before moving forward I'd like to tell that having to stop our development server, recompiling our server-side TS code, and running our dev server again every single time we make a change in our back-end is quite tedious, isn't it? We can sort that out by using nodemon
. Install nodemon
as a development dependency (npm install --save-dev nodemon
) and then create a nodemon.json
file (right next to your package.json
file) with the next content
{
"watch": ["src/server"],
"exec": "tsc -p src/server && npm run serve",
"ext": "ts"
}
We are asking nodemon
to watch files with the ts
extension in our src/server
directory, and run tsc -p src/server && npm run serve
whenever a *.ts
file inside src/server
changes. To run nodemon
with this configuration, we only need to run npx nodemon
or create a npm-script in our package.json
// package.json
{
"scripts": {
"dev": "nodemon",
...
}
}
Sharing type definitions
Great! Now whenever we change our back-end code our development server will restart automatically.
Let's now share some type definitions between our front-end and our back-end. Create and export the following interface
in src/server/index.ts
called APIIndexRouteResponse
// src/server/index.ts
export interface APIIndexRouteResponse {
message: string;
}
The response we are sending back whenever our back-end receives a GET
HTTP request to /api
already implements this interface, but it won't hurt us if we make it explicit
app.get("/api", (_, res) => {
const response = { message: "Hello world!" } as APIIndexRouteResponse;
res.json(response);
});
Now, go to App.vue
and add a async method called mounted
in which we are going to make a HTTP request to our back-end using the fetch
API
// src/App.vue
<template>
...
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { APIIndexRouteResponse } from "@/server";
@Component({})
export default class App extends Vue {
data: APIIndexRouteResponse | null = null;
async mounted() {
const res = await fetch("/api");
this.data = (await res.json()) as APIIndexRouteResponse;
}
}
</script>
<style>
...
</style>
See how we are treating the back-end response as APIIndexRouteResponse
? That's something we couldn't do if we had two different applications running, one for the front-end and another for the back-end.
Another thing to bear in mind is how we are importing type definitions. We are able to do import {...} from "@/..."
because we have and alias
set up in our tsconfig.json
that maps @/*
to our src/
directory, so we don't have to navigate up in our files tree to import something.
Conclusion
In this article we saw how to share type definitions between an Express app and a Vue app. It wasn't anything complicated, the trick was having both your front-end and back-end code in the same directory, and as an extra we saw how to host your API routes in the same server your client code is being served, to avoid CORS issues.
Hope you liked it!
📧 lucianoserruya (at) gmail (dot) com