TypeScript Todo App

Learning TypeScript #1
Mickey mouse todo app to learn the basics of TypeScript (also with Vue+Vite)

Intro

Up until now, I've never used TypeScript; the idea of learning a bunch of new syntax seemed unnecessary to me. That was until I made a decently sized codebase for my 'K-nouns' project. I noticed I would sometimes have to put comments describing what types the variables are. That's one use case for TypeScript: it makes your code more readable and maintainable. So after wrapping up that project I decided now is a good time to learn TypeScript.

Setting Up

Let's start from ground zero learning TypeScript by jumping right into the classic mickey mouse project: a todo app. I'll be using Vue+Vite since that's what I usually use. First, let's make the project with npm create vite@latest. Then in the options choose Vue and TypeScript, then it sets everything up for you. This will make the Vue+Vite app along with a bunch of files for TypeScript. I'm not sure what all the files do; I'll look into that in a later post. For now, let's jump into making the project.

TextInput.vue

Let's start by making a reusable text input component that accepts a bunch of props.

<script setup lang="ts">
interface Props {
  label?: string
  placeholder?: string,
  id: string,
  maxLen?: number,
  required?: boolean
}

const { 
  label, 
  placeholder, 
  id, 
  maxLen = 75, 
  required = true 
} = defineProps<Props>();

const model = defineModel<string>();
</script>

<template>
  <div class="input-grid">
    <label :for="id">{{ label }}</label>
    <input 
      :id="id" 
      type="text" 
      :maxlength="maxLen" 
      :required="required"
      :placeholder="placeholder"
      v-model="model"
    >
  </div>
</template>

Lets first look at

interface Props {
  label?: string
  placeholder?: string,
  id: string,
  maxLen?: number,
  required?: boolean
}

An interface is a TypeScript thing that defines the 'shape' of your data. The ? after the keys mean the prop is optional. The values are the types expected. For example, if we pass a number to the label prop or if we forget to pass the id, TypeScript will let us know with some red squigglies! Ok now let's look at the second part

const { 
  label, 
  placeholder, 
  id, 
  maxLen = 75, 
  required = true 
} = defineProps<Props>();

This is a new feature in Vue called Reactive Props Destructure. Basically it lets us assign default values to the props when we're using the TypeScript syntax. It's worth noting that there is another way to to define the props using the normal Vue method, like so:

const props = defineProps({
  label:       { type: String },
  placeholder: { type: String },
  id:          { type: String,  required: true },
  maxLen:      { type: Number,  default: 75 },
  required:    { type: Boolean, default: true }
});

We'll still get the type checking with this syntax. This is called 'runtime declaration' and the previous syntax was 'type-based declaration'. Vue has a page about it: Read more about typing props

Button.vue

Next let's make a button component that will accompany the text input component inside a form

<script setup lang="ts">
interface Props {
  text?: string
  type?: "submit" | "button",
  variant?: "primary" | "danger"
}

const { 
  text = "Submit", 
  type = "submit", 
  variant = "primary" 
} = defineProps<Props>();
</script>

<template>
  <button 
    :type="type"
    :class="variant"
  >
    {{ text }}
  </button>
</template>

Most of the stuff is the same here. Something new is the types of the 'type' and 'variant' props. These have a strings separated by |. These are called union types in TypeScript. The prop has to be one of the values listed or we'll get an error.

TodoForm.vue

Let's put our new components inside a form component

<script setup lang="ts">
import { ref } from 'vue'
import Button from './Button.vue';
import TextInput from './TextInput.vue';

const todoInput = ref('');

const emit = defineEmits<{
  submit: [todo: string]
}>()

function handleSubmit(){
  emit('submit', todoInput.value);
  todoInput.value = '';
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <TextInput 
      v-model="todoInput" 
      label="New Todo"
      id="todo-input" 
    />
    <Button text="Add Todo" />
  </form>
</template>

Here we have an emit. The syntax is much more verbose than the standard way to declare runtime emits. But with this we get type checks, so if we accidentally pass a number or an object we'll see the error before running the code. Check out the props on the TextInput component. Take out the id prop or pass a number to the label prop and you'll see TypeScript in action. We'll see the error before we run the code. Read more about typing emits

Todo.vue

There's one more component before putting everything together. But there's nothing new here. I'm using the normal way to define emits since we're not passing anything to it.

<script setup lang="ts">
import Button from './Button.vue';
const props = defineProps<{
  todo: string
}>();

const emit = defineEmits(['delete']);
</script>

<template>
  <li class="todo-wrapper">
    <span class="todo">{{ todo }}</span>
    <Button 
      @click="$emit('delete')" 
      text="Delete"
      variant="danger"
    />
  </li>
</template>

App.vue

Finally we can put everything together. First we have the TodoForm component from earlier. We listen for it's emit and then call addTodo Beneath the form, we have TransitionGroup that acts as a 'ul' tag. Inside it we loop through the todos array, passing the todo data as a prop to our Todo.vue. Remember Todo.vue had a button that emits 'delete'. When that triggers we call spliceTodo.

<script setup lang="ts">
import { ref } from 'vue'
import TodoForm from './components/TodoForm.vue';
import Todo from './components/Todo.vue';

interface TodoItem {
  id: string,
  text: string
}

const todos = ref<TodoItem[]>([]);

function addTodo(todo: string){
  todos.value.push({
    id: crypto.randomUUID(),
    text: todo
  });
}

function spliceTodo(id: string){
  const idx = todos.value.findIndex(todo => todo.id === id);
  todos.value.splice(idx, 1);
}
</script>

<template>
  <TodoForm @submit="addTodo" />

  <TransitionGroup tag="ul">
    <Todo 
      v-for="todo in todos" :key="todo.id"
      :todo="todo.text"
      @delete="spliceTodo(todo.id)" 
    />
  </TransitionGroup>
</template>

Typing Refs

Let's check out this snippet, since it's the first time I had to type a ref.

interface TodoItem {
  id: string,
  text: string
}

const todos = ref<TodoItem[]>([]);

First we have the interface, which we've seen before. When declaring the ref, before the initial value we put <TodoItem[]>. This is telling TypeScript that this ref is an array and it can only take objects that fit the shape of the TodoItem interface. If we try to pass it something else we'll get an error.

Conclusion

This was meant to be a really small project so I wrapped things up here. We ran into several new situations like typing props, emits, and refs. Even with this small project I saw the benefits of TypeScript. One thing I haven't mentioned that's really nice is that sometimes when using Vue I would forget to put .value and it would take me forever to figure out what's going on. With TypeScript that's not a problem because it will let you know immediately. This is reason enough for me to consider using TypeScript in future Vue projects. For the next TypeScript post, I'll probably look into using it without Vue and Vite just to see what's actually going on. Until next time.