Lab 1 ‐ TypeScript introduction - ftsrg-edu/ase-labs GitHub Wiki
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:
-
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.
-
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.
-
ESM (ECMAScript Modules): Modern standard using
-
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.
-
Type Safety: Critical for handling PII (e.g., ensuring
-
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.
-
npm: Installs dependencies (e.g.,
-
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.
-
TypeScript: Adds static types to JavaScript, catching errors early (e.g., invalid semester formats like
-
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.
-
ESBuild: Combines code + dependencies into a single file (e.g.,
-
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.
-
ESLint: Enforces code quality rules (e.g., banning
graph LR
A[TypeScript Code] --> B[Type Checker]
A --> C[Linter]
B --> D[Bundler]
C --> D
D --> E[Node.js Runtime]
This pipeline ensures your service chain components are robust before deployment.
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).
A starter project is available in the lab-1
branch of this repository.
Use the contents of this branch to initialize your own solution.
-
TypeScript Essentials
-
TypeScript Handbook (Official Docs)
Focus on:- Basic Types (string literals, arrays, interfaces)
- Generics (working with complex types)
- Classes (object-oriented code)
- Modules (ESM vs. CommonJS)
-
TypeScript for Java/C++ Programmers (Official Guide)
Explains TypeScript’s type system in terms of Java/C++ concepts.
-
TypeScript Handbook (Official Docs)
-
Tooling Setup
-
npm Docs: Dependency Types
Clarifies
dependencies
vs.devDependencies
. - ESBuild: Bundling for Node.js Demystifies bundling entry points and externals.
-
tsconfig.json Deep Dive (Official Reference)
Highlight:target
,module
,allowJs
, anddeclaration
.
-
npm Docs: Dependency Types
Clarifies
-
Async Patterns in TypeScript
-
Asynchronous Javascript (MDN.net)
Focus on:- Asynchronous programming concepts
-
Promises (including the
fetch
API)
-
Modern Asynchronous JavaScript/TS (JavaScript.info)
Covers concepts such asasync
/await
andPromise.all
.
-
Asynchronous Javascript (MDN.net)
- Cheat Sheets
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.
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.
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/"
}
}
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.
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.
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.
- read data from the input JSON file (compliant with the types you defined in
- Use asynchronous I/O and
await
whenever possible (see the functions defined innode: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.
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.
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 doesdist/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?
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?
In this task, we'll create two services to process our (pseudonymized) data and a client to interact with them.
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 be0
.
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.
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);
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
.
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.
Update the command-line application in index.ts
to perform the following service chain:
- Read the student data from the input file specified as the first argument.
- Pseudonymize the student data locally using the
pseudonymize.ts
module. - 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. - 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.
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.