TypeScript has taken the front end world by storm and is eating its way into the backend and fullstack world as well.
It’s important to get familiar with the tool and how best to leverage it, as it has some super powers
beyond simple string
| number
types or interface
s like type checking the format of a string or
doing exhaustive switch case checking that some may not be as familiar with.
TypeScript in the wild
If you’ve been a JavaScript or web developer in the last decade, you’ve probably noticed TypeScript eat up more and more mindshare. It’s not going away anytime soon and has completely overshadowed the likes of Flow, CoffeeScript, and others.
Percentage wise, TypeScript took home 68.4% with 69.2% of respondents choosing to answer this question on the survey.
But what is the quality of TypeScript out in the wild and is it delivering on its promises to make us more productive, our software more reliable, and our products less error-prone?
It’s hard to get a read on this with any degree of certainity, although some case studies would suggest it is returning dividends, but I would posit that most developers do not get the most bang for their buck when using TypeScript.
I hope this article can give you a slight edge as I share how I, here at Administrate, use TypeScript to deliver value reliably.
Setup
Before we get started, let’s get our local environment setup with a terminal, code editor, and of course TypeScript and Node:
- NodeJS 18
- We’ll use native NodeJS ESM (more on that later)
pnpm
(npm
or ` yarn` will work too)- TypeScript (TS) 5
- Inquirer for the CLI
- Chalk for some terminal colors
To follow along, either download the code from here or follow the setup below:
mkdir typescript-library-app
cd typescript-library-app
pnpm init
pnpm add chalk inquirer
pnpm add -D typescript @types/inquirer @types/node ts-node
The tsconfig is very minimal, the basic gist is we want modern JS feature (es2022) and to use NodeJS Native ESM (NodeNext), and have a strict typing experience so TS is doing the most work for us. We will emit our transpiled JS to the dist directory.
tsconfig.json
{
"compilerOptions": {
"lib": ["esnext"],
"outDir": "dist",
"target": "es2022",
"module": "NodeNext",
"strictFunctionTypes": true,
"strictNullChecks": true,
"noFallthroughCasesInSwitch": true,
"alwaysStrict": true
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules", "__tests__", "dist"]
}
And we’ll use a minimal set of scripts to develop with TS + Node
package.json > scripts
{
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"src": "ts-node --esm src/index.ts"
}
}
Modelling
TypeScript allows us to model our data structures and both our function parameters and return types. With a few critical type annotations, inference can take over to allow the types to flow through our applications. It’s recommended that we aim to keep type annotations as minimal as possible in application code, and instead rely on inference. This will keep us the most honest. That doesn’t mean we throw out annotations, but rather, we type the complex edges and boundaries of our apps to let TypeScript analyze our codebase.
Let’s imagine we’re running a brick-and-mortar library and our librarians require a CLI app to manage their inventory.
Let’s start by modeling a book:
interface Book {
id: string;
title: string;
author: string;
}
Now let’s see if we can model an id more strictly by using Template Literal Types, let’s assume instead of ISBN (because ISBN can be modeled by just a number within a range to fit 10 or 13 digits) our book id uses a custom format like this:
pattern:
2_DIGIT_HEX-INTEGER
ex:
ef-24200
9b-1
00-0
fe-195
ff-999999
Then the typescript could be, with some helper types:
const validChars = [
"a",
"b",
"c",
"d",
"e",
"f",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
] as const; // < - - as const is important to infer the literal!
// by indexing into the type of the array literal we get a union
// of all elements
type HEX_CHAR = typeof validChars[number];
// We can use the unions in a type template to construct all possible two-digit hex strings
type HEX_PREFIX = `${HEX_CHAR}${HEX_CHAR}`;
type BookId = `${HEX_PREFIX}-${number}`;
const makeBookId = (hex: HEX_PREFIX, num: number): BookId =>
`${hex}-${num}`;
interface Book {
id: BookId;
// ... the rest...
}
Now if we try to do something like:
makeBookId('as', 20);
we’ll get a compiler error because ‘s’ is not a valid hex digit:
Type '"as"' is not assignable to type "aa" | "ab" ...snip - ts enumerates all possible values...
Validations
Before we make our CLI app we’ll need some helper validation functions that can take in unvalidated inputs, validate them, and assert they are the new type. This is a type predicate in TS. We’ll move some types around so we can derive our types from the list of valid characters.
// For simplicity we start with true/false, but later on in the codebase
// we'll refactor to throw Errors from here. For now we let callers throw.
export function isHex(input: any): input is HEX_PREFIX {
if (typeof input !== "string") {
return false;
}
if (input.length !== 2) {
return false;
}
if (!validChars.includes(input[0] as any)) {
return false;
}
if (!validChars.includes(input[1] as any)) {
return false;
}
return true;
}
export function isId(input: any): input is BookId {
if (typeof input !== "string") {
return false;
}
if (input.length < 3) {
return false;
}
if (!input.includes("-")) {
return false;
}
const [hex, num] = input.split("-");
if (!hex || !num) {
return false;
}
if (!isHex(hex)) {
return false;
}
return isPositiveInt(num);
}
export function isPositiveInt(input: any): input is number {
if (typeof input === "string") {
const int = parseInt(input);
if (int > 0) {
return true;
}
}
return false;
}
We’re going to have an in-memory inventory of our books and we’ll encapsulate our core library
domain logic in a library.js
file, not to be confused with a programming library - this is an
irl library full of books! In addition to the types, helpers, and validation above we can store our state.
Books can be checked out or not, so we can model that with a simple array and a Set.
library.js
// internal module state
const checkedOutBooks = new Set<BookId>();
const bookList: Book[] = [];
// Expose API for our CLI to consume
export const Inventory = {
add(book: Book) {
bookList.push(book);
},
count() {
return bookList.length;
},
isCheckedOut(id: BookId) {
return checkedOutBooks.has(id);
},
checkoutById(id: BookId) {
checkedOutBooks.add(id);
},
returnById(id: BookId) {
checkedOutBooks.delete(id);
},
search(searchTerm: string) {
const searchPattern = new RegExp(searchTerm, "i");
const foundBook = bookList.find((book) => {
const titleMatch = book.title.match(searchPattern);
const authorMatch = book.author.match(searchPattern);
const idMatch = book.id.match(searchPattern);
if (idMatch) {
return true;
}
if (titleMatch) {
return true;
}
if (authorMatch) {
return true;
}
});
if (foundBook) {
return foundBook;
}
return null;
},
};
CLI
Now our CLI app will allow simple data entry, giving validation errors.
We’ll use inquirer
to get some helper cli functions. We’re only going to have a few options for our librarians to choose from,
so we can enumerate them in another const array literal, and have a union we can use in type annotations:
import inquirer, { ListQuestion } from 'inquirer';
import { Inventory } from './library.js'; // we're using native NodeJS ESM so add the .js extension
const ACTIONS = [
"Add a Book",
"Checkout a Book",
"Return a Book",
"Check Book State",
"Search for a Book",
"Count Books",
"Clear Terminal",
"Exit",
] as const;
type ACTION_TYPES = typeof ACTIONS[number];
const TOP_LEVEL_PROMPT: ListQuestion<{ action: ACTION_TYPES }> = {
type: "list",
name: "action",
message: "What would you like to do",
choices: ACTIONS,
}
// This loop is the beating heart of our app
while(true) {
const { action } = await cli.prompt([TOP_LEVEL_PROMPT]);
await dispatch(action);
}
Exhaustive Switch Case Checking
The authoring experience of writing dispatch
will already start to showcase how using TypeScript with a stricter
set of configurations and using these helper types like the constant literals will force us to do the right thing.
Let’s try implementing this dispatch
function. We are await
ing the dispatch because the CLI app inquirer models input
as a promise (ie you await
the user to provide valid input). The actions our library takes are often all synchronous, but
it’s useful to have a promise here if we need any more inputs from the user.
The type of dispatch
then will take one of the known action types, and return a void promise (ie a promise that returns nothing).
function dispatch(actionName: ACTION_TYPES): Promise<void> {
// ...
}
Since we have a small, known set of string keys, a switch is a perfect tool to reach for. And TypeScript will make sure we cover all known cases, otherwise it will error! And since it knows we can cover all the cases, we won’t need to define a default branch, because adding a new branch is a compiler error. We’ll demonstrate that here:
function dispatch(actionName: ACTION_TYPES): Promise<void> {
switch (actionName) {
case "Count Books": {
successMessage(`Library has ${Inventory.count()} books`);
return Promise.resolve();
}
}
}
Now we’re getting an error because we don’t have any of the other branches. The Function doesn’t always return what it says it will:
Function lacks ending return statement and return type does not include 'undefined'.
Let’s stub the rest of the branches. If we go back to a bare switch, the TypeScript LSP will let our editor auto-complete everything in one go:
switch (actionName) {
case "Add a Book":
case "Checkout a Book":
case "Return a Book":
case "Check Book State":
case "Search for a Book":
case "Count Books":
case "Clear Terminal":
case "Exit":
}
So now we can fill in all the implementations, knowing it won’t compile until we’re done. Since the dispatch function should be responsible only for one thing, we can call out to and create functions that fulfill the type contract.
function dispatch(actionName: ACTION_TYPES): Promise<void> {
switch (actionName) {
case "Add a Book":
return addBook();
case "Checkout a Book":
return checkoutBook();
case "Return a Book":
return returnBook();
case "Check Book State":
return checkBookState();
case "Search for a Book":
return searchForBook();
case "Count Books":
successMessage(`Library has ${Inventory.count()} books`);
return Promise.resolve();
case "Clear Terminal":
console.clear();
return Promise.resolve();
case "Exit":
successMessage("Good bye 👋 (your library inventory will be purged)");
process.exit(0);
}
}
If you define all of these functions with stub implementations, it will compile fine, yay! Reviewing the code, you may be tempted to do a refactoring like:
function dispatch(actionName: ACTION_TYPES): Promise<void> {
switch (actionName) {
case "Add a Book":
return addBook();
case "Checkout a Book":
return checkoutBook();
case "Return a Book":
return returnBook();
case "Check Book State":
return checkBookState();
case "Search for a Book":
return searchForBook();
case "Count Books": // error!
successMessage(`Library has ${Inventory.count()} books`);
case "Clear Terminal": // error!
console.clear();
case "Exit":
successMessage("Good bye 👋 (your library inventory will be purged)");
process.exit(0);
}
return Promise.resolve();
}
But, luckily, TypeScript knows better here. It isn’t a good idea to fallthrough these switch cases as you
may unintentionally allow a fallthrough to happen from a case that should be handled differently. Our tsconfig
setting noFallthroughCasesInSwitch
protects us against this mistake, and keeps this function clean and tidy to boot.
Here’s an example of implementing the prompts and cli app logic for something like adding a book, the rest is in the GitHub repo:
const BOOK_ID_HEX_PROMPT: InputQuestion<{ idPrefix: string }> = {
type: "input",
name: "idPrefix",
message: "Book ID Prefix: ",
validate(input: string) {
const cleanInput = input.trim().toLowerCase();
if (isHex(cleanInput)) {
return true;
}
throw new Error(
"Invalid hex prefix, please provide two hex characters (0-9a-f)"
);
},
};
const BOOK_ID_PROMPT: InputQuestion<{ id: string }> = {
type: "input",
name: "id",
message: "Book ID Number: ",
validate(input: string) {
if (isPositiveInt(input.trim())) {
return true;
}
throw new Error("Invalid number, please pass a valid positive integer");
},
};
const BOOK_TITLE_PROMPT: InputQuestion<{ title: string }> = {
type: "input",
name: "title",
message: "Book Title: ",
};
const BOOK_AUTHOR_PROMPT: InputQuestion<{ author: string }> = {
type: "input",
name: "author",
message: "Book Author Name: ",
};
async function addBook() {
const answers = await cli.prompt<{
title: string;
author: string;
id: string;
idPrefix: string;
}>([
BOOK_ID_HEX_PROMPT,
BOOK_ID_PROMPT,
BOOK_TITLE_PROMPT,
BOOK_AUTHOR_PROMPT,
]);
const { idPrefix, id, title, author } = answers;
const cleanPrefix = idPrefix.trim().toLowerCase();
// we know this will pass because of the validation from the prompt,
// but doing this gets us type narrowing from the type predicate
if (isHex(cleanPrefix)) {
Inventory.add(
makeBook(
makeBookId(cleanPrefix, parseInt(id.trim())),
title.trim(),
author.trim()
)
);
successMessage(`${title} added.`);
}
}
Putting it all together gives us a CLI app experience like this:
More Type Safety
We were able to leverage type safety around our core ID and Book types, and even our dispatch function, but we’re having to redo some work to get type narrowing working properly with this untyped CLI app, see here:
async function addBook() {
const answers = await cli.prompt<{
title: string;
author: string;
id: string;
idPrefix: string;
}>([
BOOK_ID_HEX_PROMPT,
BOOK_ID_PROMPT,
BOOK_TITLE_PROMPT,
BOOK_AUTHOR_PROMPT,
]);
const { idPrefix, id, title, author } = answers;
const cleanPrefix = idPrefix.trim().toLowerCase();
if (isHex(cleanPrefix)) { // < --- can we get this "for free" because we already validated?
// ... snip ...
Even though we have a typings package alongside this app, because it isn’t ‘TypeScript native’ issues
like this can occur where we may have to re-introduce types we already gave the library, can we remove the
need to do this redundant isHex
call?
Yes! But we will need to wrap inquirer
to do so, providing a more type-safe
and validation-aware API to inquirer. We could explore adding something like zod to our stack here,
but since inquirer isn’t ‘TS native’, we would have to do the same amount of work anyway with our existing type predicates.
Mapped Types
Let’s try to leverage our type predicates in a new cli.ts
file so that when we prompt, we get type inference.
First let’s try to create a stricter shape for input, something like:
cli.ts
interface TypeSafeInput
We will want two type parameters, one is the validated type after we use our type predicate, and the other is the name of the input for inquirer, which we will wrap.
interface TypeSafeInput<
TValidatedInput,
TName extends string
> extends InputQuestion<{ Something: TValidatedInput }>
I’ve added Something
in the return type generic because we want the string we use for the name to flow into InputQuestion,
so naively we may want to use an index type with TName to do that:
interface TypeSafeInput<
TValidatedInput,
TName extends string
> extends InputQuestion<{ [TName]: TValidatedInput }>
This doesn’t work, because the TypeScript syntax needs a unique symbol here. We know TName
has to be a string because of
our generic constraint extends string
, so what we can do is get the keys in this Type to get to the underlying string as
a computed key for our resulting object, the docs for that are in the Mapped Types section and use key in T
syntax.
The Docs use keyof
because they are working with Types that are not keys themselves, but in our case we know it is a key, so we
can just map over it with in
and bind the string to the key
intermediate name (that can be named anything, key is just convention):
interface TypeSafeInput<
TValidatedInput,
TName extends string
> extends InputQuestion<{ [key in TName]: TValidatedInput }>
It might make more sense to rename key
to name
in our case:
interface TypeSafeInput<
TValidatedInput,
TName extends string
> extends InputQuestion<{ [name in TName]: TValidatedInput }>
This will reinforce that the special sauce is the in
operator, not the left-hand side type variable name.
The actual implementation is fairly straightforward:
interface TypeSafeInput<
TValidatedInput,
TName extends string
> extends InputQuestion<{ [name in TName]: TValidatedInput }> {
type: "input";
name: TName;
message: string;
parse: (input: any) => TValidatedInput;
}
If we try to introduce the typePredicate
’s parameter here we end up duplicating the fact that InputQuestion
already
has a validation function, we can make a convenience function to create our type safe input to make this easier and
still accept a proper type predicate, but we’ll name it validate
for convenience.
If there is any separate parsing work from the type predicate, we can do those after in a function that post processes inquirer’s prompt function. In away this supports the existing API of inquirer anyway.
Here’s a helper create function for our type safe input:
// We don't always need to do anything after the booleanp predicate is true,
// but other times we do need to parse the string to another type.
const identity = <T>(x: T) => x;
export const createInput = <T, N extends string>(
name: N,
message: string,
validate: (input: any) => input is T,
parse?: (input: any) => T
): TypeSafeInput<T, N> => ({
type: "input",
name,
message,
validate,
parse: parse || identity
});
Now let’s create our own prompt
function to get more type safety in our functions:
export const prompt = async <TI, TN extends string>(input: TypeSafeInput<TI, TN>) => {
const answer = await cli.prompt(input);
return input.parse(answer[input.name]);
}
Inference Sets You Free
Now we can use the power of type inference to get some nice DX, let’s try out this new prompt on an integer and our two-character hex prefix:
const testHexPrompt = createInput("hex", "Give me 2 hex digits", isHex);
const testAgePrompt = createInput("age", "Give me your age", isPositiveInt, parseInt);
const testHex = await prompt(testHexPrompt);
const testAge = await prompt(testAgePrompt);
Hovering over testHex
and testAge
shows that we get full type inference, even for fancy types like our Hex Prefix! And
we can pass type predicates and parser functions directly to our createInput
helper. Here we’ve named both of these "test"
because
we’re only doing them one at a time, so let’s see if we can support an array of type-safe inputs and get TypeScript to & them all
together.
First, let’s just try to make a prompt function that takes in an array and sprinkle in any
s and we’ll try to tackle the types one by one:
export async function prompt(inputs: TypeSafeInput<any, any>[]) {
const answers = await cli.prompt(inputs);
return inputs.reduce((accum, curr) => {
const parsedValue = curr.parse(answers[curr.name]);
accum[curr.name] = parsedValue;
return accum;
}, {} as any);
}
We’re essentially doing the same thing, but using reduce
on the input array and building an object with the keys mapped to the parsed values.
Now to tackle the types, if we provide the minimum generics first:
export async function prompt<T1, TN1 extends string>(inputs: TypeSafeInput<T1, TN1>[]) {
const answers = await cli.prompt(inputs);
return inputs.reduce((accum, curr) => {
const parsedValue = curr.parse(answers[curr.name]);
accum[curr.name] = parsedValue;
return accum;
}, {} as any);
}
We know we expect a simple object with TN1: T1
so let’s write a quick couple of helper type aliases:
type SimpleObject<TKey extends string, TValue> = { [name in TKey]: TValue };
type SO<TK extends string, TV> = SimpleObject<TK, TV>;
And throw that in the return type:
export async function prompt<T1, TN1 extends string>(inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
const answers = await cli.prompt(inputs);
return inputs.reduce((accum, curr) => {
const parsedValue = curr.parse(answers[curr.name]);
accum[curr.name] = parsedValue;
return accum;
}, {} as any);
}
Now the reducer’s object initialization can be better typed as we know the result is a simple object:
export async function prompt<T1, TN1 extends string>(...inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
const answers = await cli.prompt(inputs);
return inputs.reduce((accum, curr) => {
const parsedValue = curr.parse(answers[curr.name]);
accum[curr.name] = parsedValue;
return accum;
}, {} as SO<TN1, T1>);
}
Function Overloading
This implementation works for any size array, but the typings won’t be right. We can provide overloads, reusing the same implementation, but expanding on the number of parameters with tuple types. This is how some utility libraries like lodash work under the hood. It may seem tedious, but the copy-pasta mostly completes itself.
export async function prompt<
T1, TN1 extends string
>(
i1: TypeSafeInput<T1, TN1>
): Promise<SO<TN1, T1>>;
Now we copy this same overload type definition for T2 through to the T4 variants, completing the pie 🥧.
export async function prompt<
T1, TN1 extends string,
T2, TN2 extends string,
T3, TN3 extends string,
T4, TN4 extends string
>(
i1: TypeSafeInput<T1, TN1>,
i2: TypeSafeInput<T2, TN2>,
i3: TypeSafeInput<T3, TN3>,
i4: TypeSafeInput<T4, TN4>
): Promise<SO<TN1, T1> & SO<TN2, T2> & SO<TN3, T3> & SO<TN4, T4>>;
export async function prompt<
T1, TN1 extends string,
T2, TN2 extends string,
T3, TN3 extends string,
>(
i1: TypeSafeInput<T1, TN1>,
i2: TypeSafeInput<T2, TN2>,
i3: TypeSafeInput<T3, TN3>
): Promise<SO<TN1, T1> & SO<TN2, T2> & SO<TN3, T3>>;
export async function prompt<
T1, TN1 extends string,
T2, TN2 extends string,
>(
i1: TypeSafeInput<T1, TN1>,
i2: TypeSafeInput<T2, TN2>
): Promise<SO<TN1, T1> & SO<TN2, T2>>;
export async function prompt<
T1, TN1 extends string,
>(
i1: TypeSafeInput<T1, TN1>
): Promise<SO<TN1, T1>>;
export async function prompt<T1, TN1 extends string>(...inputs: TypeSafeInput<T1, TN1>[]): Promise<SO<TN1, T1>> {
const answers = await cli.prompt(inputs);
return inputs.reduce((accum, curr) => {
const parsedValue = curr.parse(answers[curr.name]);
accum[curr.name] = parsedValue;
return accum;
}, {} as SO<TN1, T1>);
}
At this point, we can start refactoring our app to use this new prompt
! The types of our predicates and parsers
will flow out of each prompt
invocation so redundant type narrowing or checking is unnecessary now. This is particularly
helpful for the add book flow:
const BOOK_ID_HEX_PROMPT = createInput("idPrefix", "Book ID Prefix: ", isHex, cleanHex);
const BOOK_ID_PROMPT = createInput("id", "Book ID Number: ", isPositiveInt, parseInt);
const BOOK_TITLE_PROMPT = createInput("title", "Book Title: ", isNonEmptyString);
const BOOK_AUTHOR_PROMPT = createInput("author", "Book Author: ", isNonEmptyString);
async function addBook() {
const { idPrefix, id, title, author } = await prompt(
BOOK_ID_HEX_PROMPT,
BOOK_ID_PROMPT,
BOOK_TITLE_PROMPT,
BOOK_AUTHOR_PROMPT
);
Inventory.add(
makeBook(
makeBookId(idPrefix, id),
title.trim(),
author.trim()
)
);
successMessage(`${title} added.`);
}
It also makes defining all our prompts one-liners which is a nice bonus. After trawling through the codebase, we can remove some imports, make sure errors are being thrown from the type predicates and validators, and move our parsers around a bit to ensure we get the right types all while the compiler is there checking our work.
One of the biggest challenges when working on the web platform is just knowing what’s going on and what the shape
of things is going to be, whether that’s data over the wire or even the libraries that are supposed to accelerate our
productivity. Just sprinkle of TypeScript around a library function like cli.prompt
can go a long way to supporting
our applications.
Now that we’re confident in our app, can we add a feature easily?
Persistence
Our librarians are delighted with the app but hate when their cli session is closed and they lose the entire library. They started asking for backup/restore actions, but they just want the state persisted to disk. Let’s do that!
First, we need to wrap the state into one object/interface, and create some helper functions for the JSON functions:
interface State {
books: Book[],
checkedOutBooks: Set<BookId>
};
let state: State = {
books: [],
checkedOutBooks: new Set()
};
function replacer(key: string, value: string) {
if (key === "checkedOutBooks") {
return Array.from(value);
}
return value;
}
function reviver(key: string, value: string) {
if (key === "checkedOutBooks") {
if (Array.isArray(value)) {
return new Set(value);
}
throw new Error("Invalid state, checkedOutBooks must be saved as an array");
}
return value;
}
Now that this boilerplate is out of the way, we can write the serialize and restore functions:
import { readFile writeFile } from "fs/promises";
const FILE_PATH = "state.data";
function saveState() {
const stringifiedState = JSON.stringify(state, replacer);
const utf8Buff = Buffer.from(stringifiedState, "utf-8");
const base64EncodedStateString = utf8Buff.toString("base64");
return writeFile(FILE_PATH, base64EncodedStateString);
}
async function restoreState() {
try {
const file = await readFile(FILE_PATH)
const b64String = file.toString();
const str = Buffer.from(b64String, "base64").toString("utf-8");
state = JSON.parse(str, reviver)
return state;
} catch (err) {
return state;
}
}
restoreState();
And we just update all instances in the library.js
that access state, and boom, we have stored the state to disk in a base 64
encoded file. TypeScript wasn’t the most helpful, but it does make sure we’re using Buffer’s and strings correctly, which is an easy
mistake when using these node APIs.
What’s next?
I highly recommend the following places to dig deeper into TypeScript and type safe web application development in the JS ecosystem:
- Matt Pocock’s Total Typescript course.
- The TypeScript Handbook.
- Check out Colin McDonnell’s blog and work.
- Build an app with tRPC for end to end type safe client and server interactions.