TypeScript Tutorial: Building a Simple Web-Based Recipe Manager

In today’s digital age, we’re constantly bombarded with recipes. From family favorites to culinary experiments, keeping track of them can be a challenge. Wouldn’t it be great to have a simple, organized way to store, manage, and share your recipes online? This tutorial will guide you through building a web-based recipe manager using TypeScript, empowering you to create a digital cookbook you can access from anywhere.

Why TypeScript?

TypeScript, a superset of JavaScript, brings static typing to your code. This means you can catch errors early, improve code readability, and make your projects more maintainable. For a project like a recipe manager, where you’ll be dealing with data structures representing recipes, ingredients, and instructions, TypeScript’s type safety is invaluable.

What We’ll Build

Our recipe manager will have the following features:

  • Adding new recipes with title, ingredients, and instructions.
  • Viewing existing recipes.
  • Editing recipes.
  • Deleting recipes.
  • A simple, user-friendly interface.

Prerequisites

Before we begin, make sure you have the following installed:

  • Node.js and npm (Node Package Manager): These are essential for managing project dependencies and running our application.
  • A code editor: Visual Studio Code, Sublime Text, or any editor you prefer.
  • Basic knowledge of HTML, CSS, and JavaScript.

Setting Up the Project

Let’s start by creating a new project directory and initializing it with npm:

mkdir recipe-manager
cd recipe-manager
npm init -y

This will create a `package.json` file in your project directory. Next, we’ll install TypeScript and some development dependencies:

npm install typescript --save-dev
npm install @types/node --save-dev

We install `@types/node` to provide type definitions for Node.js built-in modules. Now, let’s create a `tsconfig.json` file to configure TypeScript:

npx tsc --init

This command generates a `tsconfig.json` file with default settings. We’ll modify it to suit our project. Open `tsconfig.json` and make these changes:

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Here’s what each setting does:

  • target: "ES2015": Specifies the ECMAScript target version for the emitted JavaScript code.
  • module: "commonjs": Specifies the module system.
  • outDir: "./dist": Sets the output directory for compiled JavaScript files.
  • rootDir: "./src": Specifies the root directory of your source files.
  • strict: true: Enables strict type-checking options.
  • esModuleInterop: true: Enables interoperability between CommonJS and ES modules.
  • skipLibCheck: true: Skips type checking of declaration files.
  • forceConsistentCasingInFileNames: true: Enforces consistent casing in file names.
  • include: ["src/**/*"]: Includes all files within the `src` directory in the compilation.

Project Structure

Let’s create the following project structure:


recipe-manager/
├── src/
│   ├── index.ts
│   ├── recipe.ts
│   └── ...
├── dist/
├── node_modules/
├── package.json
├── tsconfig.json
└── ...

The `src` directory will contain our TypeScript source files, and the `dist` directory will hold the compiled JavaScript files.

Creating the Recipe Model

Let’s define the structure of our recipes. Create a file named `recipe.ts` inside the `src` directory:

// src/recipe.ts

export interface Ingredient {
  name: string;
  amount: string;
}

export interface Recipe {
  id: number;
  title: string;
  ingredients: Ingredient[];
  instructions: string[];
}

This code defines two interfaces: `Ingredient` and `Recipe`. `Ingredient` has `name` and `amount` properties, and `Recipe` has `id`, `title`, `ingredients`, and `instructions` properties. We’re using an interface for type safety, ensuring that our data conforms to a specific structure.

Building the Recipe Manager Logic

Now, let’s create the core logic for our recipe manager in `index.ts`:

// src/index.ts
import { Recipe, Ingredient } from './recipe';

let recipes: Recipe[] = [];
let nextRecipeId = 1;

// Function to add a new recipe
function addRecipe(title: string, ingredients: Ingredient[], instructions: string[]): void {
  const newRecipe: Recipe = {
    id: nextRecipeId++,
    title,
    ingredients,
    instructions,
  };
  recipes.push(newRecipe);
  console.log("Recipe added:", newRecipe);
}

// Function to view all recipes
function viewRecipes(): void {
  if (recipes.length === 0) {
    console.log("No recipes found.");
    return;
  }
  recipes.forEach((recipe) => {
    console.log(`Recipe ID: ${recipe.id}`);
    console.log(`Title: ${recipe.title}`);
    console.log("Ingredients:");
    recipe.ingredients.forEach((ingredient) => console.log(`  - ${ingredient.name}: ${ingredient.amount}`));
    console.log("Instructions:");
    recipe.instructions.forEach((instruction, index) => console.log(`  ${index + 1}. ${instruction}`));
    console.log("------------------");
  });
}

// Function to edit a recipe
function editRecipe(id: number, updatedRecipe: Partial): void {
  const recipeIndex = recipes.findIndex((recipe) => recipe.id === id);
  if (recipeIndex === -1) {
    console.log("Recipe not found.");
    return;
  }
  recipes[recipeIndex] = { ...recipes[recipeIndex], ...updatedRecipe };
  console.log("Recipe updated:", recipes[recipeIndex]);
}

// Function to delete a recipe
function deleteRecipe(id: number): void {
  recipes = recipes.filter((recipe) => recipe.id !== id);
  console.log("Recipe deleted.");
}

// Example Usage
addRecipe(
  "Chocolate Chip Cookies",
  [
    { name: "Flour", amount: "2 cups" },
    { name: "Sugar", amount: "1 cup" },
  ],
  [
    "Preheat oven to 375°F (190°C).",
    "Mix ingredients.",
    "Bake for 10 minutes.",
  ]
);

addRecipe(
  "Pasta Carbonara",
  [
    { name: "Spaghetti", amount: "200g" },
    { name: "Eggs", amount: "2" },
    { name: "Pancetta", amount: "100g" },
  ],
  [
    "Cook pasta.",
    "Fry pancetta.",
    "Mix eggs and cheese.",
    "Combine all ingredients.",
  ]
);

viewRecipes();

editRecipe(1, { title: "Best Chocolate Chip Cookies" });

deleteRecipe(2);

viewRecipes();

Let’s break down this code:

  • We import the `Recipe` and `Ingredient` interfaces from `recipe.ts`.
  • recipes: An array to store our recipes.
  • nextRecipeId: A variable to automatically assign unique IDs to each recipe.
  • addRecipe(): This function takes the title, ingredients, and instructions as arguments, creates a new `Recipe` object, adds it to the `recipes` array, and logs the new recipe to the console.
  • viewRecipes(): This function iterates through the `recipes` array and prints each recipe’s details to the console.
  • editRecipe(): This function takes the recipe ID and an object containing the updated recipe data. It finds the recipe by ID, updates its properties using the spread operator, and logs the updated recipe. We use `Partial` to allow updating only specific fields.
  • deleteRecipe(): This function takes a recipe ID and removes the recipe from the `recipes` array using the `filter` method.
  • Example Usage: We demonstrate how to use the functions by adding, editing, and deleting recipes.

Compiling and Running the Code

Now, let’s compile our TypeScript code into JavaScript:

tsc

This command will compile all `.ts` files in your `src` directory and output the corresponding `.js` files into the `dist` directory. You can then run the compiled JavaScript using Node.js:

node dist/index.js

You should see the recipe details printed in your console. This confirms that our basic recipe manager is working.

Adding a User Interface (HTML & CSS)

While the console output is functional, let’s create a simple HTML and CSS interface for a better user experience. We’ll create a basic `index.html` file in the root of our project. This example uses inline styles for simplicity, but in a real-world application, you’d use a separate CSS file.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Recipe Manager</title>
  <style>
    body {
      font-family: sans-serif;
      margin: 20px;
    }
    .recipe-container {
      border: 1px solid #ccc;
      padding: 10px;
      margin-bottom: 10px;
    }
    .ingredient-list, .instruction-list {
      margin-left: 20px;
    }
    input[type="text"], textarea {
      margin-bottom: 5px;
      width: 100%;
      padding: 5px;
    }
    button {
      padding: 8px 15px;
      background-color: #4CAF50;
      color: white;
      border: none;
      cursor: pointer;
      margin-right: 5px;
    }
  </style>
</head>
<body>
  <h2>Recipe Manager</h2>

  <div id="recipe-form">
    <h3>Add Recipe</h3>
    <input type="text" id="recipe-title" placeholder="Recipe Title"><br>
    <textarea id="recipe-ingredients" placeholder="Ingredients (comma-separated)"></textarea><br>
    <textarea id="recipe-instructions" placeholder="Instructions (line-separated)"></textarea><br>
    <button onclick="addRecipeFromForm()">Add Recipe</button>
  </div>

  <div id="recipe-list">
    <h3>Recipes</h3>
    <div id="recipes-container"></div>
  </div>

  <script>
    // JavaScript code will go here
  </script>
</body>
</html>

This HTML sets up a basic form for adding recipes and a container to display them. Now, let’s add JavaScript code to interact with our TypeScript logic. Update the `script` tag in `index.html` with the following code:


  // JavaScript code
  const recipeTitleInput = document.getElementById('recipe-title');
  const recipeIngredientsInput = document.getElementById('recipe-ingredients');
  const recipeInstructionsInput = document.getElementById('recipe-instructions');
  const recipesContainer = document.getElementById('recipes-container');

  function addRecipeFromForm() {
    const title = recipeTitleInput.value;
    const ingredientsString = recipeIngredientsInput.value;
    const instructionsString = recipeInstructionsInput.value;

    const ingredients = ingredientsString.split(',').map(item => {
      const [name, amount] = item.trim().split(':').map(s => s.trim());
      return { name, amount };
    }).filter(item => item.name && item.amount);

    const instructions = instructionsString.split('n').map(instruction => instruction.trim()).filter(instruction => instruction);

    // Call the TypeScript function to add the recipe
    window.addRecipe(title, ingredients, instructions);

    // Clear the form
    recipeTitleInput.value = '';
    recipeIngredientsInput.value = '';
    recipeInstructionsInput.value = '';

    // Refresh the recipe list
    displayRecipes();
  }

  function displayRecipes() {
    recipesContainer.innerHTML = ''; // Clear existing recipes

    window.recipes.forEach(recipe => {
      const recipeElement = document.createElement('div');
      recipeElement.classList.add('recipe-container');
      recipeElement.innerHTML = `
        <h4>${recipe.title}</h4>
        <p>Ingredients:</p>
        <ul class="ingredient-list">
          ${recipe.ingredients.map(ingredient => `<li>${ingredient.name}: ${ingredient.amount}</li>`).join('')}
        </ul>
        <p>Instructions:</p>
        <ol class="instruction-list">
          ${recipe.instructions.map(instruction => `<li>${instruction}</li>`).join('')}
        </ol>
        <button onclick="deleteRecipeFromForm(${recipe.id})">Delete</button>
      `;
      recipesContainer.appendChild(recipeElement);
    });
  }

  function deleteRecipeFromForm(recipeId) {
    window.deleteRecipe(recipeId);
    displayRecipes();
  }

  // Initial display
  displayRecipes();
</script>

This JavaScript code does the following:

  • Gets references to the input fields and the recipes container.
  • addRecipeFromForm(): This function reads the values from the form, parses the ingredients and instructions, calls the `addRecipe` function (which we’ll make globally available), clears the form, and refreshes the recipe list.
  • displayRecipes(): This function clears the existing recipe list, iterates through the `recipes` array, and creates HTML elements to display each recipe’s details, including a delete button.
  • deleteRecipeFromForm(): This function calls the `deleteRecipe` function (which we’ll make globally available) and refreshes the display.
  • Initial Display: Calls `displayRecipes()` to render the initial recipes.

To make the TypeScript functions accessible from the HTML, we need to expose them globally. Modify `src/index.ts` to include the following lines at the end of the file:


(window as any).addRecipe = addRecipe;
(window as any).recipes = recipes;
(window as any).deleteRecipe = deleteRecipe;

This adds the `addRecipe`, `recipes`, and `deleteRecipe` functions to the global `window` object, making them accessible from our HTML. Now, recompile your TypeScript code (`tsc`) and open `index.html` in your browser. You should see the form and the recipes displayed. You can add new recipes, and delete them.

Adding More Features (Editing)

Let’s add an edit functionality. We’ll modify the `displayRecipes` function to include an edit button and an edit form. Add the following code inside the `displayRecipes()` function, within the forEach loop, before the delete button.


      recipeElement.innerHTML += `
        <button onclick="editRecipeFromForm(${recipe.id})">Edit</button>
        <div id="edit-form-${recipe.id}" style="display:none; margin-top: 10px;">
          <input type="text" id="edit-title-${recipe.id}" placeholder="Recipe Title" value="${recipe.title}"><br>
          <textarea id="edit-ingredients-${recipe.id}" placeholder="Ingredients (comma-separated)">${recipe.ingredients.map(ing => `${ing.name}: ${ing.amount}`).join(', ')}</textarea><br>
          <textarea id="edit-instructions-${recipe.id}" placeholder="Instructions (line-separated)">${recipe.instructions.join('n')}</textarea><br>
          <button onclick="saveRecipe(${recipe.id})">Save</button>
          <button onclick="cancelEdit(${recipe.id})">Cancel</button>
        </div>
      `;

This adds an edit button. When clicked, it reveals an edit form pre-populated with the recipe’s current data. Let’s create the related JavaScript functions to manage the edit form, inside the `script` tag in `index.html`:


  function editRecipeFromForm(recipeId) {
    const editForm = document.getElementById(`edit-form-${recipeId}`);
    editForm.style.display = 'block';
  }

  function cancelEdit(recipeId) {
    const editForm = document.getElementById(`edit-form-${recipeId}`);
    editForm.style.display = 'none';
  }

  function saveRecipe(recipeId) {
    const title = document.getElementById(`edit-title-${recipeId}`).value;
    const ingredientsString = document.getElementById(`edit-ingredients-${recipeId}`).value;
    const instructionsString = document.getElementById(`edit-instructions-${recipeId}`).value;

    const ingredients = ingredientsString.split(',').map(item => {
      const [name, amount] = item.trim().split(':').map(s => s.trim());
      return { name, amount };
    }).filter(item => item.name && item.amount);

    const instructions = instructionsString.split('n').map(instruction => instruction.trim()).filter(instruction => instruction);

    window.editRecipe(recipeId, { title, ingredients, instructions });
    displayRecipes();
  }

This code does the following:

  • editRecipeFromForm(recipeId): Shows the edit form for the specified recipe.
  • cancelEdit(recipeId): Hides the edit form.
  • saveRecipe(recipeId): Reads data from the edit form, calls the `editRecipe` function (which we’ll make globally available), and refreshes the recipe list.

To make the `editRecipe` function accessible from the HTML, we need to expose it globally in `src/index.ts`:


(window as any).addRecipe = addRecipe;
(window as any).recipes = recipes;
(window as any).deleteRecipe = deleteRecipe;
(window as any).editRecipe = editRecipe;

Recompile (`tsc`), refresh your browser, and now you should be able to edit recipes.

Handling Errors and Edge Cases

Our current implementation is functional, but it could be improved by handling errors and edge cases. For instance:

  • Input Validation: We should validate the user’s input in the form to ensure that they enter valid data (e.g., ingredients should have both a name and an amount).
  • Error Messages: Display informative error messages to the user if something goes wrong (e.g., if a recipe cannot be found).
  • Empty Input: Handle cases where the user submits empty fields.

Let’s add some basic input validation to the `addRecipeFromForm` and `saveRecipe` functions in `index.html`. Add the following checks before calling the corresponding functions:


  function addRecipeFromForm() {
    const title = recipeTitleInput.value;
    const ingredientsString = recipeIngredientsInput.value;
    const instructionsString = recipeInstructionsInput.value;

    if (!title) {
      alert('Please enter a recipe title.');
      return;
    }

    const ingredients = ingredientsString.split(',').map(item => {
      const [name, amount] = item.trim().split(':').map(s => s.trim());
      return { name, amount };
    }).filter(item => item.name && item.amount);

    if (ingredients.length === 0) {
        alert('Please enter at least one ingredient.');
        return;
    }

    const instructions = instructionsString.split('n').map(instruction => instruction.trim()).filter(instruction => instruction);

    if (instructions.length === 0) {
        alert('Please enter at least one instruction.');
        return;
    }

    // Call the TypeScript function to add the recipe
    window.addRecipe(title, ingredients, instructions);

    // Clear the form
    recipeTitleInput.value = '';
    recipeIngredientsInput.value = '';
    recipeInstructionsInput.value = '';

    // Refresh the recipe list
    displayRecipes();
  }

  function saveRecipe(recipeId) {
    const title = document.getElementById(`edit-title-${recipeId}`).value;
    const ingredientsString = document.getElementById(`edit-ingredients-${recipeId}`).value;
    const instructionsString = document.getElementById(`edit-instructions-${recipeId}`).value;

    if (!title) {
        alert('Please enter a recipe title.');
        return;
    }

    const ingredients = ingredientsString.split(',').map(item => {
      const [name, amount] = item.trim().split(':').map(s => s.trim());
      return { name, amount };
    }).filter(item => item.name && item.amount);

    if (ingredients.length === 0) {
        alert('Please enter at least one ingredient.');
        return;
    }

    const instructions = instructionsString.split('n').map(instruction => instruction.trim()).filter(instruction => instruction);

    if (instructions.length === 0) {
        alert('Please enter at least one instruction.');
        return;
    }

    window.editRecipe(recipeId, { title, ingredients, instructions });
    displayRecipes();
  }

This code adds basic validation to ensure the title, ingredients, and instructions are filled before attempting to add or save a recipe. It uses `alert()` for simple error messages, but you could use more sophisticated methods, such as displaying error messages near the corresponding input fields.

Styling and Enhancements

Our recipe manager is functional, but it could use some styling and additional features to make it more user-friendly. Here are some ideas:

  • CSS Styling: Improve the visual appearance of the application using CSS. Consider using a CSS framework like Bootstrap or Tailwind CSS for easier styling.
  • Ingredient Input Improvements: Instead of a single text area for ingredients, use a dynamic input field where users can add ingredients one by one.
  • Instruction Input Improvements: Similarly, provide a way to add instructions one at a time, perhaps with numbered list items.
  • Search Functionality: Add a search bar to allow users to easily find recipes by title or ingredients.
  • Local Storage: Use local storage to persist the recipes so that they are not lost when the user closes the browser.
  • Responsive Design: Make the application responsive so it works well on different screen sizes.
  • User Authentication: For a more advanced application, add user accounts and the ability to share recipes.

Key Takeaways

  • TypeScript enhances JavaScript with static typing, improving code quality and maintainability.
  • Interfaces define the structure of your data, ensuring type safety.
  • Modular design makes your code more organized and easier to manage.
  • Basic HTML, CSS, and JavaScript are essential for building web applications.
  • Error handling and input validation are crucial for a robust application.

FAQ

Here are some frequently asked questions about this tutorial:

  1. Why use TypeScript instead of JavaScript?

    TypeScript adds static typing to JavaScript, which helps catch errors early, improves code readability, and makes your projects more maintainable, especially for larger applications.

  2. How do I deploy this application?

    You can deploy this application by hosting the HTML file and the compiled JavaScript file on a web server, such as Netlify, Vercel, or GitHub Pages. You would also need to ensure the HTML file references the correct paths to your JavaScript files.

  3. Can I use a framework like React or Angular with TypeScript?

    Yes, TypeScript is often used with frameworks like React, Angular, and Vue.js. TypeScript provides excellent support for these frameworks, including type checking and autocompletion.

  4. What are some common mistakes to avoid?

    Common mistakes include not compiling the TypeScript code before running the application, not correctly referencing the compiled JavaScript in the HTML, and forgetting to expose the TypeScript functions globally. Also, not handling edge cases or validating user input can lead to unexpected behavior.

  5. How can I improve the user interface?

    You can improve the user interface by using CSS to style the application, using a CSS framework like Bootstrap or Tailwind CSS to make styling easier, and adding more user-friendly input elements, such as dynamic input fields for ingredients and instructions.

This tutorial has provided a foundation for building a web-based recipe manager using TypeScript. By understanding the core concepts and building blocks presented, you can expand upon this project, adding more features, improving the user interface, and refining the overall user experience. The combination of TypeScript’s type safety and the flexibility of HTML, CSS, and JavaScript opens up a wide range of possibilities for creating powerful and maintainable web applications. Remember to always consider error handling, input validation, and user experience when developing your projects. Happy coding, and enjoy creating your digital cookbook!