Lab 1 ‐ TypeScript introduction - ftsrg-edu/ase-labs GitHub Wiki

Introduction

Throughout this course, we'll develop a Domain-Specific Language (DSL) and VSCode-based tooling for dataspaces, Personally Identifying Information- (PII) and consent-aware data integration environment. A central concept in a dataspace is a service chain, an orchestration of various data sources and services performing a data analysis task while ensuring data subject privacy and regulatory compliance.

In this lab, you’ll build a foundational service chain component for educational data pseudonymization and aggregation. This exercise introduces the TypeScript toolchain and modern JavaScript environments, critical for developing the DSL and VSCode-based tooling in later labs. Below are the core concepts and tools you’ll master:

Key Concepts

  1. JavaScript Runtime Environments

    • Node.js: The backend runtime for CLI tools and REST services (used here for file I/O and HTTP handling).
    • Browser vs. Node: While browsers focus on DOM/UI, Node.js enables system-level operations (file access, servers) – essential for dataspace tooling.
    • Different environments often support different language features or built-in functions, so we need transpilers to convert between language versions and polyfills to add missing built-in functions.
  2. Module Systems

    • ESM (ECMAScript Modules): Modern standard using import/export syntax.
    • CommonJS: Legacy Node.js system (require()/module.exports). We avoid it except for compatibility with older libraries (e.g., express).
    • Converting between different module systems is usually the responsibility of transpilers or bundlers.
  3. Dataspace Relevance

    • Type Safety: Critical for handling PII (e.g., ensuring studentId is pseudonymized before aggregation).
    • Tooling Reliability: Industrial-grade toolchains prevent errors in privacy-sensitive service chains.

Tooling Stack

  1. Command Runner & Package Manager

    • npm: Installs dependencies (e.g., nanoid for pseudonymization) and runs scripts (build, lint, test).
    • Why It Matters: Manages tooling for collaborative DSL development.
  2. Type Checker

    • TypeScript: Adds static types to JavaScript, catching errors early (e.g., invalid semester formats like ["Fall", 2025]).
    • Dataspace Use Case: Enforces strict data shapes for PII and service chain outputs.
  3. Bundler

    • ESBuild: Combines code + dependencies into a single file (e.g., dist/index.js).
    • Why It Matters: Required for packaging VSCode plugins and DSL interpreters in later labs.
  4. Linter

    • ESLint: Enforces code quality rules (e.g., banning == to prevent type coercion bugs).
    • Dataspace Use Case: Ensures consistency in large teams managing sensitive data pipelines.

Workflow Overview

graph LR  
  A[TypeScript Code] --> B[Type Checker]  
  A --> C[Linter]  
  B --> D[Bundler]  
  C --> D  
  D --> E[Node.js Runtime]  
Loading

This pipeline ensures your service chain components are robust before deployment.

Looking Ahead

In Lab 2, you’ll use these tools with Langium to define a DSL grammar for service chains. The TypeScript types you write today will evolve into DSL-generated code for:

  • PII-aware data transformations.
  • Automated compliance checks.
  • VSCode plugin integration (syntax highlighting, validation).

Starter project

A starter project is available in the lab-1 branch of this repository.

Use the contents of this branch to initialize your own solution.

Preliminaries

Suggested reading

  1. TypeScript Essentials
  2. Tooling Setup
  3. Async Patterns in TypeScript
  4. Cheat Sheets

Check the environment

First, let us verify that a Node 20 compatible environment and NPM 11 is installed.

While the latest long-term support version of Node is Node 22, we'll stick to Node 20, because this is the version used by VSCode for executing extension. In the next lab, we'll develop a VSCode extension ourselves, so it is important to use a compatible Node version and avoid using APIs not available in the VSCode runtime.

$ node --version
v20.18.1
$ npm --version
11.1.0
Instructions for installing Node and NPM (not needed in the VM)

If you're working on your own computer without the course virtual machine, you may use nvm to install Node 20.18.1. Afterwards, you can upgrade NPM to the latest version with the command

npm install -g npm

The -g flag signifies that npm should be upgraded globally and not just for a particular project.

Task 1: Simple TypeScript application (5 points)

Creating an NPM package

You'll need to create a package.json file to manage the dependencies and development dependencies of your project. NPM can help you if you issue the npm init command in the directory where you want your new package.

The example below creates a new package in the directory called lab-1 and creates a package.json for an NPM package with the same name inside. Take a look at the information NPM asks for (user input is highlighted in bold) and the resulting package.json file.

To enable the use of ES modules, we set "type": "module". This will make Node interpret plain .js files as EM modules instead of CommonJS scripts.

$ cd lab-1
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help init` for definitive documentation on these fields
and exactly what they do.

Use `npm install ` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (meres) lab-1
version: (1.0.0) 
description: An example package
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) UNLICENSED
type: (commonjs) module
About to write to /home/meres/ase-example/package.json:

{
  "name": "lab-1",
  "version": "1.0.0",
  "description": "An example package",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "UNLICENSED",
  "type": "module"
}


Is this OK? (yes) yes

Warning

NPM uses "license": "UNLICENSED" in package.json to denote packages that are not available under an open-source license. Do not confuse this with The Unlicense, which dedicates all code to the public domain.

Setting up TypeScript

First, we'll need to install typescript as a development dependency. We also install the type information for the NodeJS 20 runtime, which lets TypeScript typecheck our use of built-in functions.

npm add -D typescript @types/node@20

Use the command npx tsc --init to create a new tsconfig.json file in your repository.

By default, the tsconfig.json file will compile your TypeScript to ECMAScript 2016 and CommonJS. Since we're running on Node 20, we can update tsconfig.json to compile to ECMAScript 2023 and ES modules according to the recommendations from TypeScript. We also enable emitting .d.ts declaration files and set the output directory to "./dist".

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "Node16",
    "declaration": true,
    "outDir": "./dist/"
  }
}

Writing type definitions

Take a look at the files computer-engineering.json and electrical-engineering.json, which contain synthetic data for testing an educational data management system.

In a new TypeScript module (e.g., Student.ts), create an interface or type declaration named Student to describe the data about students. Create additional types or interfaces as needed.

Some further information about the data:

  • The overall JSON files describe JSON arrays of type Student[].
  • Each Student in the array has a name, student ID, and an array of grades.
  • The grades array describes the courses taken by the student, including the semester, the name of the course, the number of credits, whether the student has earned a signature, and the grade (if any).
  • The semester is described by a two-element array. The first element is the year, while the second element is always one of "Spring" and "Fall".
  • Students with no signatures will receive no grade.
  • Students with a signature will receive an integer grade between 1 and 5 (inclusive), or no grade at all if they don't attend any exams.

Try to incorporate as many of these requirements into the type definitions as possible! Which types should be exported from this module?

CHECK: Add the Students.ts file with the type declarations into the repository.

Implementing pseudonymiziation

To complete this task, you'll need to install the nanoid library into your project for the generation of random identifiers. It should be installed as a normal (not development) dependency, since we'll use it in the application itself:

npm install nanoid

Create a new file named pseudonymize.ts and implement a function to protect Personally Identifiable Information (PII) in the educational data. The file should export the following function:

export default function pseudonymize<
  T extends { name: string; studentId: string },
>(data: readonly T[]): Omit<T, 'name'>[];

What is the meaning of this function signature? Use nanoid to replace the value of the PII field studentId with newly generated random identifiers.

CHECK: Implement this function and add it to your repository.

You may add new helper functions to pseudonymize.ts, but you shouldn't export them.

Implementing a CLI application

Create a new file called index.ts and implement a simple command-line application.

  • The application should take 2 command-line arguments: the name of an input JSON file, and the name of the output JSON file.
  • It should
    • read data from the input JSON file (compliant with the types you defined in Student.ts),
    • pseudonymize them (using the function you defined in pseudonymize.ts), then
    • write the results to the output JSON file, overwriting it if it already exists.
  • Use asynchronous I/O and await whenever possible (see the functions defined in node:fs/promises).
  • Make sure to format the output JSON to be human-readable.

To aid you, here is a skeleton for the application:

import { readFile, writeFile } from 'node:fs/promises';

import type { Student } from './Student.js';
import pseudonymize from './pseudonymize.js';

if (process.argv.length !== 4) {
  console.error('Usage: node dist/index.js <INPUT-FILE> <OUTPUT-FILE>');
  process.exit(1);
}

// TODO Implement asynchonous I/O and pseudonymiziation.

Why do we require process.argv.length === 4 to take 2 arguments?

Why do the imports have a .js suffix instead of .ts?

CHECK: Implement the rest of the application and add it to your repository.

Running TypeScript

In order to make our application runnable, we have to use TypeScript to transpile our .ts files to .js.

Let's add a command to the "scripts" section of your package.json:

{
  "scripts": {
    "typecheck": "tsc"
  }
}

Now you can run the command npm run typecheck to let TypeScript process your source files.

Take a look at the contents of the dist/ directory. What is the role of .d.ts and .js files?

CHECK: Run your application with node dist/index.js computer-engineering.json computer-engineering-pseudo.json to pseudonymize the data of the Computer Engineering students and add the output JSON file to your repository.

Bundling the application

The output of TypeScript contains an explicit import of the dependency nanoid in dist/pseudonymize.js. This means your application can only be run after installing the dependencies with to the node_modules directory by issuing the command npm install or npm install --production.

To avoid having to install dependencies separately, we can use a bundler to merge source files and dependencies into a single .js file. In this course, we'll use ESBuild as a bundler.

To start, let's install ESBuild as an NPM development dependency:

npm install -D esbuild

Save the following script as esbuild.mjs. It is based on the ESBuild setup for the Langium DSL engineering framework that we'll use on the next lab.

//@ts-check
import * as esbuild from 'esbuild';

const watch = process.argv.includes('--watch');
const minify = process.argv.includes('--minify');

const ctx = await esbuild.context({
  entryPoints: ['index.ts'],
  outdir: 'dist',
  bundle: true,
  target: 'ES2023',
  format: 'esm',
  loader: { '.ts': 'ts' },
  platform: 'node',
  sourcemap: !minify,
  minify,
});

if (watch) {
  await ctx.watch();
} else {
  await ctx.rebuild();
  ctx.dispose();
}

We define a single entry point for the application, main.ts. ESBuild will compile all the code imported from this module into a single output file.

Also modify tsconfig.json to prevent TypeScript from outputting JavaScript code, since from now on, this will be ESBuild's job, We'll still keep .d.ts files to preserve type information.

{
  "compilerOptions": {
    "emitDeclarationOnly": true
  }
}

Let us add a new script to package.json to invoke ESBuild:

{
  "scripts": {
    "build": "node esbuild.mjs"
  }
}

To try out the new workflow, remove all the files in dist/ and run npm run build.

CHECK: Write your observations to your documentation about the following questions:

  • What can you see in the dist/index.js file?
  • Try passing the --minify argument (i.e., npm run build -- --minify). How does dist/index.js change?
  • What happens if you run npm run build -- --watch in the background, then edit your source files?
  • How did the behavior of npm run typecheck change?

Static analysis

Since JavaScript may have lots of surprising behaviors (e.g., == vs ===), it is often useful to use static analysis to ensure that our code is of high quality.

In this task, we'll use ESLint for static analysis. Your may install it as

npm install -D eslint

Now you may run

npx eslint --init

to create a new ESLint configuration. Select the following options:

✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · typescript
✔ Where does your code run? · node
✔ Would you like to install them now? · No / Yes
✔ Which package manager do you want to use? · npm

Now go ahead and follow the instructions for enabling Linting with Type Information from the ESLint documentation.

You will replace your ESLint configuration in eslint.config.js with something like this:

import path from 'node:path';
import { fileURLToPath } from 'node:url';

import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  { files: ['**/*.{js,mjs,cjs,ts}'] },
  { ignores: ['dist/', 'node_modules/'] },
  { languageOptions: { globals: globals.node } },
  pluginJs.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: path.dirname(fileURLToPath(import.meta.url)),
      },
    },
  },
);

Let us add a new script to package.json to invoke ESLint:

{
  "scripts": {
    "lint": "eslint"
  }
}

If you run npm run lint now, ESLint will complain about some files in your project not being covered by tsconfig.json. We can adjust tsconfig.json to get rid of these warning as follows:

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
  },
  "include": ["**/*.ts", "**/*.js", "**/*.cjs", "**/*.mjs"],
  "exclude": ["./dist", "./node_modules"]
}

Run npm run lint to analyze your project. You can also select the ESLint: Restart ESLint Server from the VSCode command palette (Ctrl+Shift+P) to display warnings and errors in your editor.

CHECK: Fix all the warnings and errors in your project and commit the changes to your repository. What was the reason for the errors?

Task 2: REST services (5 points)

In this task, we'll create two services to process our (pseudonymized) data and a client to interact with them.

Data processing

Create a new module called statistics.ts. Implement functions with the following signatures (create new type declarations and export them from Student.ts as appropriate):

export function computeAverages(data: readonly PseudonymizedStudent[]): StudentAverages[];

export function computePassRates(data: readonly PseudonymizedStudent[]): CoursePassRates[];

The output of computeAverages should be similar to the following, but contain an object for each (pseudonymized) student:

[
  {
    "studentId": "9R59ir_z_ByCU_mH1OxVu",
    "averages": [
      {
        "semester": [
          2025,
          "Spring"
        ],
        "credits": 17,
        "average": 4.529411764705882,
        "correctedAverage": 2.5666666666666667
      },
      {
        "semester": [
          2025,
          "Fall"
        ],
        "credits": 19,
        "average": 2.578947368421052,
        "correctedAverage": 1.6333333333333333
      }
    ]
  }
]
  • The number of credits taken by a student is the sum of their courses' credits in particular semester.
  • The "average" of a student is the credit weighted average of their passing (2 or better) grades, divided by the number of credits taken.
  • The "correctedAverage" of a student is the credit weighted average of their passing (2 or better) grades, divided by 30.
  • If a student hasn't earned any passing grades in a semester, both "average" and "correctedAverage" should be 0.

The output of computePassRates should be similar to the following, but contain an object for each course:

[
  {
    "courseName": "Data Structures and Algorithms",
    "passRates": [
      {
        "semester": [
          2025,
          "Spring"
        ],
        "signatureRate": 0.8333333333333334,
        "passRate": 0.3333333333333333
      },
      {
        "semester": [
          2024,
          "Spring"
        ],
        "signatureRate": 0.90625,
        "passRate": 0.375
      },
    ]
  }
]
  • The signature rate of a course is the number of students earning a signature divided by the number of students taking the course in a given semester.
  • The pass rate of a course is the number of students earning a passing (2 or better) grade divided by the number of students taking the course in a given semester.

To test your code, it might be convenient to modify your index.ts to act as a command-line interface for the function you're developing.

NOTE: In JavaScript, arrays and objects are only considered equal with themselves (there is no deep equality to check the equality of array elements or object members), so you can't use them as keys for a Map. However, strings and numbers can be keys, and you can turn a complex object into a key by serializing it as JSON.

Methods such as .map() and .forEach() (or the for ( ... of ... ) loop) of Array may also be convenient.

HINT

Here is an example implementation of the computeAverages function:

interface StudentAccumulator {
  credits: number;

  sumGrades: number;
}

export function computeAverages(data: readonly PseudonymizedStudent[]): StudentAverages[] {
  return data.map(({ studentId, grades }) => {
    const map = new Map<string, StudentAccumulator>();
    for (const grade of grades) {
      const key = JSON.stringify(grade.semester);
      let accumulator = map.get(key);
      if (accumulator === undefined) {
        accumulator = { credits: 0, sumGrades: 0 };
        map.set(key, accumulator);
      }
      accumulator.credits += grade.credits;
      if ('grade' in grade && grade.grade !== undefined && grade.grade >= 2) {
        accumulator.sumGrades += grade.credits * grade.grade;
      }
    }

    return {
      studentId,
      averages: Array.from(map.entries()).map(([semesterString, { credits, sumGrades }]) => ({
          semester: JSON.parse(semesterString) as Semester,
          credits,
          average: credits > 0 ? sumGrades / credits : 0,
          correctedAverage: credits / 30,
        })),
      };
  });
}

CHECK: Save the averages and pass rates from the computer-engineering.json (after pseudonymization) into the repository.

REST service

Implement a new Express web service in a new module called server.ts.

The service should expose two POST endpoints, /averages and /passRates. The endpoints should read the JSON array of pseudonymized students from the request body, and return the array of student semester averages or course pass rates, respective, as JSON in the response body.

Install express v5 as follows. You'll need to install TypeScript type declarations for development separately.

npm install express@5
npm install -D @types/express@5

You may need to restart the ESLint server (Ctrl+Shift+P > ESLint: Restart ESLint Server) so that it can pick up the new type declarations.

The following example service parses the JSON request body, wraps it in another object, then returns it as JSON in the response body. The service will run on http://localhost:3000 or on the port provided in the PORT environmental variable. You may use it as a starting point:

import express from 'express';

const PORT = parseInt(process.env['PORT'] ?? '3000', 10);

const app = express();

// Add the JSON parsing middleware to the application to parse JSON request bodies.
app.use(express.json({ limit: '50mb' }));

app.post('/ping', (req, res) => {
  const data = req.body as unknown;
  res.json({
    success: true,
    request: data,
  })
});

app.listen(PORT);

Bundling the REST service

In order to run this application, you'll need to add server.ts as an entry point to esbuild.mjs in addition to main.ts. However, you may encounter the issue when running the resulting dist/server.js bundle, because express is a CommonJS module that can't be directly included in an ESM bundle.

We can simply mark express as external to omit it from the bundle. Note that this will require installing express separately. Alternatively, we could make ESBuild output a CommonJS bundle, but we would loose a few ECMAScript features, such as top-level await expressions.

To mark express as external, modify esbuild.mjs as follows:

const ctx = await esbuild.context({
  // ...
  external: ['express'],
});

CHECK: Run the server and verify that it listens on the specified port.

You may use the following command to try the example endpoint:

curl -X POST -H 'Content-Type: application/json' -d '[{"a": "b"}, {"c": 1}]' http://localhost:3000/ping

To try your own endpoints, use the @filename syntax of the -d cURL option, e.g., write -d @computer-engineering-pseudonymized.json to send the contents of computer-engineering-pseudonymized.json.

REST client

Create a new module named ServiceChainClient.ts and implement the following class:

export default class ServiceChainClient {
  constructor(apiBase: string);

  async computeAverages(data: readonly PseudonymizedStudent[]): Promise<StudentAverages[]>;

  async computePassRates(data: readonly PseudonymizedStudent[]): Promise<CoursePassRates[]>;
}

The apiBase constructor argument signifies the server where the REST service from the previous task is deployed. For example, if apiBase === "http://localhost:3000/", then computeAverages should initiate a request to http://localhost:3000/averages.

Use the built-in fetch function to initiate an HTTP POST request. Don't forget to set the Content-Type: application/json request header and the request body.

Service chain client application

Update the command-line application in index.ts to perform the following service chain:

  1. Read the student data from the input file specified as the first argument.
  2. Pseudonymize the student data locally using the pseudonymize.ts module.
  3. Initiate two requests to the REST server in parallel to compute student semester averages and course pass rates. The REST server is deployed to the location indicated by the API_BASE environmental variable.
  4. Write the output of the two requests to the output file specified as the second argument.

The output files should have the following format:

{
  "averages": /* Add the student semester averages here */,
  "passRages": /* Add the course pass rates here */
}

NOTE: To manage parallel requests, the static methods of Promise may be useful.

CHECK: Add the application to your repository and verify that it runs successfully by issuing the API_BASE=http://localhost:3000/ node dist/index.js computer-engineering.json computer-engineering-statistics.json command.

Extra task: JSON validation (3 IMsc points)

Up until this point, we've been simply casting incoming JSON data (of type unknown) to our declared types (e.g., as Student[]). This is prone to errors, since TypeScript doesn't check at runtime whether the data conforms to the type. Invalid incoming data will lead to crashes in our service.

Manually checking whether the incoming JSON data conforms to a type declaration requires tedious, repetitive coding, and we'd have to make sure that the validation actually matches our type declarations. This creates a maintenance problem.

A better alternative is to use a library to create a validator and type declarations at the same time. Use the zod library to create a type-safe JSON validator!

In order to do this, you'll need to replace your type declaration with zod schemas. Use z.infer<T> to create TypeScript types from your schema.

Modify your REST service to validate input data. Also modify your REST client to validate data returned by the service.

NOTE: Make sure that you haven't deleted { "compilerOptions": { "strict": true } } from tsconfig.json, as zod requires this setting to function properly.

CHECK: Create the schemas and update your server and client. Build and run the server and the client to make sure that they are still able to communicate.

⚠️ **GitHub.com Fallback** ⚠️