Implementing Concepts in TypeScript
- Modularity in web applications
- Wrapping Express.js
- Concepts in TypeScript
- Persistent Storage for State
- Actions and Syncs into Web APIs
- Unit Testing
- A bit of engineering philosophy
- Resources
This tutorial will walk you through implementing your concepts in TypeScript. Reading it carefully is crucial for A4 and subsequent assignments.
Modularity in web applications
If you already have web development experience, some of this may sound unfamiliar to you. Most of it, however, is just a straightforward application of modularity principles that you already know (e.g., from 6.102), combined with the extra modularity that concepts bring. At the end of this note, some of the rationale is discussed more.
Wrapping Express.js
Before we get into implementing concepts, let’s understand the way we’ll be implementing the web routes first. The small framework we provide allows an easy and nice way to implement web APIs. If you have used Express before, you know how it’s annoying to deal with req
and res
or forget calling next()
in a middleware. Handling errors in express is another big headache too — in addition to having to do return
right after sending a response, you might remember asyncHandler from 6.102. We solve these problems by adding a wrapper over Express.js.
Router
from framework/router.ts
provides functionality to decorate TypeScript methods with the method and the route, like @Router.get("/users")
. Refer to Lecture 8 slides to get a refresher on designing APIs. The methods either throw errors or return results, both of which will be sent to the caller as a response.
For example, this is how we would create a super simple random number generator between 0 and 99:
class Routes {
@Router.get("/random")
async getRandomNumber() {
return Math.floor(Math.random() * 100);
}
}
If we want to provide, for example, query parameters like /random?min=40&max=150
, we can easily do that by adding min
and max
to the function parameters (note that query parameters are always of string
type, so we would need to do the casting ourselves):
class Routes {
@Router.get("/random")
async getRandomNumber(min: string, max: string) {
const minNum = min ? parseInt(min) : 0; // if not given, default to 0
const maxNum = max ? parseInt(max) : minNum + 100; // if not given, default to minNum + 100
return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
}
}
Here comes the best part! Let’s say, we want to do some error handling. If min
and max
are not numbers (e.g., /random?min=abc
), we want to send an error message. We can simply throw an error:
class Routes {
@Router.get("/random")
async getRandomNumber(min: string, max: string) {
const minNum = min ? parseInt(min) : 0;
const maxNum = max ? parseInt(max) : minNum + 100;
if (isNaN(minNum) || isNaN(maxNum)) {
throw new Error("Invalid min/max!");
}
return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
}
}
The framework uses HTTP_CODE
property on an error class to send a status code, and the default is 500 Internal Server Error
. You can find useful errors under concepts/errors.ts
to use, but let’s define one here as well:
class BadValuesError extends Error {
public readonly HTTP_CODE = 400;
}
class Routes {
@Router.get("/random")
async getRandomNumber(min: string, max: string) {
const minNum = min ? parseInt(min) : 0;
const maxNum = max ? parseInt(max) : minNum + 100;
if (isNaN(minNum) || isNaN(maxNum)) {
throw new BadValuesError("Invalid min/max!");
}
return Math.floor(Math.random() * (maxNum - minNum)) + minNum;
}
}
This kind of error handling especially comes super useful when in your route you call another function that throws an error — because now you don’t need to catch it and forward it to res
object from Express
. Now, let’s understand more exactly how this framework works.
Router.get(endpoint)
represents a handler for a GET
method request to the endpoint
. Other methods are also supported. The parameter names (e.g., min
, max
in case of above) of the decorated function are read from the web request directly. Here’s how it works:
- If the parameter name is
session
, then it will represent the express-session object (i.e.,req.session
). This session is persisted through the program runtime for the same requester (determined by cookies). - If parameter name is
params
,query
, orbody
, it’s read from thereq: Request
object directly. Remind yourself of them from 6.102 notes. - Otherwise, it will be searched for in these objects in order:
req.params
,req.query
,req.body
. E.g., for the/random
route above, there are noparams
andGET
requests cannot have a body, so thequery
is the only option. However, if it was aPOST
request for some reason (not a good idea, for this endpoint), having the parameters either in the query or body would be fine!
As you saw above, if there are errors thrown during the handling, the error will be sent to the front-end with its message and status code. More about this in a later section. We will also see later how to simplify our code for validating parameters with another decorator, @Router.validate
.
Let’s now implement the code for login
, editPost
, and createPost
routes in an app where there is no authentication (you can login as any username). The implementations here are only to understand better how the route handling works and some details are left out. We will revisit them again in a later section.
// no DB yet: `posts` is a mapping from post id to the post
const posts = new Map<string, { author: string; content: string }>();
let nextId = 0;
// `session` directly comes from `req` object
@Router.post("/login")
async login(session, username: string) {
session.user = username;
}
// `content` might either come from query or body
@Router.post("/posts")
async createPost(session, content: string) {
if (!session.user) throw new UnauthenticatedError("Log in first!");
const user = session.user;
posts.set(
nextId.toString(), // id of this post
{ author: user, content } // the post itself
);
nextId++;
}
// `id` in the method comes from the URL parameter `:id`
@Router.patch("/posts/:id")
async editPost(session, id: string, newContent: string) {
if (!session.user) throw new UnauthenticatedError("Log in first!");
if (!posts.has(id)) throw new NotFoundError("Post not found!");
const post = posts.get(id);
if (post.author !== session.user) throw new NotAllowedError("Nope!");
post.content = newContent;
}
Keep in mind that query and URL parameters can only be string
values. The body of the request, however, can be anything JSON
supports (e.g., a parameter of type Array<string>
). Unfortunately, we do not have static checking for the argument types in our routing functions: your code will still compile and run even though, e.g., you put number
for a type that is actually a string
. Since it’s converted into JavaScript, all the types are going to be ignored at runtime. So, be careful with these!
Here’s a recap:
- Use
@Router.httpMethod(endpoint)
decorators on functions to declare web APIs. session
,params
,query
, andbody
are special keywords and they will be read from the request directly (e.g.,params
would represent URL parameters).- All other parameter names are exported from these in that order:
req.params
,req.query
,req.body
. - Use
string
for URL params and queries, but body can be any JSON-supported type.
Now that you have a better understanding of how route handling works, let’s switch over to implementing concepts and synchronizations, which then will be converted into routes like above!
Concepts in TypeScript
Implementing Concepts
One thing we have emphasized since their introduction is that concepts are completely modular and self-contained. This means, if we have Posting
and Commenting
concepts, they should be able to work together without even knowing that the other exists. In this framework, we show you a design pattern to achieve this kind of modularity in our backend code.
Before we get to the “web” part, we want to be able to implement concepts. While the big idea behind concept-driven development is to move beyond and away from classic OOP, classes nevertheless provide a good way to implement concepts. Let’s look at the Authenticating
concept from Lecture 4.
concept Authenticating
purpose Authenticate users so that app users correspond to people
operational principle
after a user registers with a username and password pair,
they can authenticate as that user by providing a matching
username and passwordstate
registered: set User
username, password: User -> one Stringactions
register (username, password: String, out u: User)
authenticate (username, password: String, out u: User)
Let’s now implement this in TypeScript:
interface UserObj {
username: string;
password: string;
}
/**
* Purpose: Authenticate users so that app users correspond to people
* Principle: After a user registers with a username and password pair,
* they can authenticate as that user by providing a matching username and password
*/
class AuthenticatingConcept {
// State: username, password: registered -> one string
private registered: Array<UserObj>;
public register(username: string, password: string): UserObj {
const existing = this.registered.find((user) => user.username === username);
if (existing !== undefined) {
throw new Error("This username is taken!");
}
const newUser: UserObj = { username, password };
this.registered.push(newUser);
return newUser;
}
public authenticate(username: string, password: string): UserObj {
const existing = this.registered.find((user) => {
return user.username === username && user.password === password;
});
if (existing === undefined) {
throw new Error("Username or password is wrong!");
}
return existing;
}
}
The AuthenticatingConcept
class is actually a generator of authenticating concepts, so we start by creating an instance of this class:
const Authing = new AuthenticatingConcept();
We can now call actions create
and authenticate
like this:
Authing.create("barish", "salam123");
try {
Authing.authenticate("barish", "salam111");
console.log("Auth succeeded!");
} catch (err) {
console.log(`Auth failed: ${err}`);
}
Now, to make things a bit more interesting, let’s also create a Posting
concept (a simplified version of a social media posting concept). Note that the AuthorT
we use here is a generic type (a type variable).
interface PostObj<AuthorT> {
author: AuthorT;
content: string;
_id: number; // a PostObj is identified by numeric _id
}
/**
* Purpose: Users can post objects for other users
* Principle: After making a post, that post is availble to other users
*/
class PostingConcept<AuthorT> {
private posts: Array<PostObj<AuthorT>>;
private nextId = 0; // used to add ids to the posts
public create(author: AuthorT, content: string) {
const postObj: PostObj<AuthorT> = { author, content, _id: this.nextId };
this.posts.push(postObj);
this.nextId++;
return { msg: "Post created!", postObj };
}
public get(_id: number) {
return this.posts.find((postObj) => postObj._id === _id);
}
public update(_id: number, newContent: string) {
const postObj = this.get(_id);
if (postObj === undefined) {
throw new Error("Post not found!");
}
postObj.content = newContent; // post is a reference to the post inside this.posts
return { msg: "Post edited!" };
}
}
When defining our app, we would instantiate this concept like Posting[Authenticating.User]
. In our code, User
is the type UserObj
we defined earlier, so let’s instantiate the PostingConcept
:
const Posting = new PostingConcept<UserObj>();
Now we can use these concepts together:
const user = Authing.authenticate("barish", "salam123");
const { msg, postObj } = Posting.create(user, "hi!");
Since the AuthorT
type in PostingConcept
is generic, we could also use, for example, string
representing the username of the author:
const Posting = new PostConcept<string>();
This allows us to create posts with code like Posting.create("barish", "hi")
without passing in the whole user object from Authing
concept. However, this is probably not what you want because now you can’t let users change their usernames! This problem doesn’t come up when the author
field is a reference to the UserObj
instance.
It might also be tempting to call Posting.create
like this:
const { msg, postObj } = Posting.create({ username: "barish", password: "123" }, "hi!");
This is bad for two reasons. One, we are creating a new UserObj
type object that will not match the user object Authing
has in its state. If you remember it from 6.102, equality checking for non-primitive types in TypeScript is complicated and even has its own whole reading. Thus, postObj.author === Authing.getUserByUsername("barish")
will be false for the code above (assuming getUserByUsername
returns the UserObj
matching the given username), making it impossible to get posts by given user. Two, we can’t just go around and create UserObj
as we want — we are breaking the encapsulation provided by AuthingConcept
by doing that.
In practice, we’re not going to store our data with objects, we’re going to use a database, as we’ll see shortly.
This was a lot! Here are some key points to recap what we talked in this section so far:
- We implement concepts using TypeScript classes. Keep in mind that these classes are not defining ADTs as was so common in 6.102: one
AuthingConcept
instance represents a concept that manages all users, not just a single user. - Very often concepts will have generic type parameters, like
PostingConcept
hasAuthorT
. Inside the concept implementation, we can’t make any assumptions about this type — for example, insidePostingConcept
, we can’t assumeAuthorT
will be similar toUserObj
and will have properties likeusername
andpassword
. As you saw, it’s possible for it to be just astring
, too. - To use concepts, we instantiate them first and then call their actions. Most times, concepts are used together — this is what we call synchronizations, the topic of the next section.
Concept Synchronizations
Let’s take a step back and look closer at these Posting.create
and Posting.update
actions. You might ask, how come we are allowing them to be used arbitrarily and don’t have to check if the caller of an action (e.g., logged in user) is really the author of that post?
From PostingConcept
’s perspective, there are no permissions or rules about who edits the post. This is an important separation of concerns. Moreover, the Posting concept doesn’t even know about users, except that that some type AuthorT
contains the objects that play the role of authors of posts.
If Posting
can’t make such an important decision about who is the one creating or editing the post, how will we implement it correctly? That’s where synchronization comes in.
Let’s say, we also require username
and password
to create a post (rather than just an author
). Then we would sync Authing
and Posting
like this:
/**
* sync createPost(username, password, content):
* Authing.authenticate(username, password, [out] user)
* Posting.create(user, content)
*/
function createPost(username: string, password: string, content: string) {
// If the username password pair does not exist, Authing.authenticate will throw an error.
// Thus, it will not allow creating a post without proper authentication.
const user = Authing.authenticate(username, password);
return Posting.create(user, content);
}
Notice here how we are using Authing
concept only to provide authentication, as we discussed in Lecture 4. In a web application, it wouldn’t be very convenient to provide a username and password for every interaction, so we use a Session-ing
concept as you saw both in Lecture 4 and Prep 3. This is how we would implement it with the session instead:
/**
* sync createPost(session, content):
* Sessioning.getUser(session, [out] user)
* Posting.create(user, content)
*/
function createPost(session: SessioningObj, content: string) {
const user = Sessioning.getUser(session);
return Posting.create(user, content);
}
Now, let’s also see how updatePost
works out. We need some way of verifying that the user from Sessioning
is the author of the post
that’s being edited. One nice way to do this is to simply get the post object and check its author:
function updatePost(session: SessioningObj, id: string, newContent: string) {
const user = Sessioning.getUser(session); // we store username in the session
const postObj = Posting.get(parseInt(id)); // post objects have numeric IDs
if (postObj.author !== user) {
throw new Error(`${user} is not the author of post ${postObj}`);
}
return Post.edit(postObj, newContent);
}
Checking if a given user is the author of a post could also be made into an action in Posting
concept:
// inside PostConcept class
public assertAuthorIsUser(_id: number, user: AuthorT) {
const postObj = this.posts.find((postObj) => postObj.id === _id);;
if (postObj.author !== user) {
throw new Error(`${user} is not the author of post ${_id}`)
}
}
Notice that assertAuthorIsUser
does not return anything or do any changes to the state — it is used as what you might call a “validator.” It could seem weird that we call it an action, but note that we still use the state to decide if an error needs to happen or not — we simplify our workflow by throwing errors rather than returning a boolean that tells us if the author is permitted to edit the post or not. This is like the permit
action defined in the Karma concept here.
Now we would also be able to implement a front-facing updatePost
function like this as well:
function updatePost(session: SessioningObj, id: string, newContent: string) {
const user = Sessioning.getUser(session);
const _id = parseInt(id);
Posting.assertAuthorIsUser(_id, user);
return Posting.update(_id, newContent);
}
Both ways are legal in terms of concept modularity, but as we discussed, it’s not the responsibility of Posting
to check for ownership, so you might find the first way more intuitive. However, as you add more actions, you might find yourself doing this check often, so sometimes it’s useful to have it as an action like above as well.
If you have any questions about anything above, please ask!
Persistent Storage for State
Notice that the state we have in our program so far lives in the memory (RAM) of the computer, and we would lose all of it if the program shuts down. So we need a persistent storage method: a database. In this class, for its convenience and good integration with TypeScript, we’ll be using MongoDB and its library for Node.js. Since MongoDB calls objects “documents”, we’ll follow the same convention and call our interfaces UserDoc
, PostDoc
, etc. instead of UserObj
or PostObj
.
We also provide a small wrapper around the MongoDB driver in framework/doc.ts
for your convenience. It’s still recommended and very useful to read the fundamentals documentation.
When we switch to a database, we lose one crucial property we had before: we can’t rely on object references for equality. For example, previously, we could be sure that when we create a post with author being UserObj
, that object will be the same object as the one in Authenticating
concept.
const user = Authing.authenticate("barish", "123");
const { msg, postObj } = Posting.create(user, "my post!");
assert(postObj.author === user); // true!
When we store documents in the database, we load them as needed, and copies won’t have the same reference. Instead, we use the unique document identifiers that MongoDB creates for us, which will be of type ObjectId
. This means that although are concepts are just as generic as before, we no longer have generic parameters in the code: they will always be ObjectId
. In the Posting
concept, for example, we will use ObjectId
in the PostDoc
definition instead of AuthorT
.
We provide a utility BaseDoc
interface and DocCollection
class to handle database operations in framework/doc.ts
.
BaseDoc
will provide fields _id
, dateCreated
, and dateUpdated
, and all of these are managed by DocCollection
— you should not need to modify them directly.
Here’s how we would implement PostingConcept
with all these changes:
interface PostDoc extends BaseDoc {
author: ObjectId;
content: string;
// (and from BaseDoc: _id, dateCreated, dateUpdated)
}
class PostingConcept {
public readonly posts: DocCollection<PostDoc>;
constructor(collectionName: string) {
this.posts = new DocCollection<PostDoc>(collectionName);
}
async create(author: ObjectId, content: string, options?: PostOptions) {
const _id = await this.posts.createOne({ author, content, options });
return {
msg: "Post successfully created!",
post: await this.posts.readOne({ _id }),
};
}
async get(post: ObjectId) {
return await this.posts.readOne({ _id });
}
async edit(post: ObjectId, newContent: string) {
await this.posts.updateOne({ _id }, { content: newContent });
return { msg: "Post edited!" };
}
}
The constructor now takes a parameter: the name of the MongoDB collection we’ll use to store this concept instance’s data. We’ll discuss this more in a moment under Concept Reuse with MongoDB. For now it means we instantiate the concept like this:
const Posting = new PostingConcept("posts");
Concepts are not limited to having a single collection. We also provide a Friending
concept in the starter code, so let’s take a quick look at how it’s designed. We store both the friendships and friend requests in the concept. If (u1, u2)
friendship exists, that means u1
and u2
are friends ((u2, u1)
friendship doesn’t need to exist — in fact, the full code only allows exactly one of these pairs to exist). We can also make nice use of MongoDB’s convenient filters, as you can learn from its documentation and in Recitation 4. Take a look at how getRequests
gets all friend requests for a given user.
export interface FriendshipDoc extends BaseDoc {
user1: ObjectId;
user2: ObjectId;
}
export interface FriendRequestDoc extends BaseDoc {
from: ObjectId;
to: ObjectId;
status: "pending" | "rejected" | "accepted";
}
export default class FriendingConcept {
public readonly friends: DocCollection<FriendshipDoc>;
public readonly requests: DocCollection<FriendRequestDoc>;
/**
* Make an instance of Friending.
*/
constructor(collectionName: string) {
this.friends = new DocCollection<FriendshipDoc>(collectionName);
this.requests = new DocCollection<FriendRequestDoc>(collectionName + "_requests");
}
// Get all requests from or to the user
async getRequests(user: ObjectId) {
return await this.requests.readMany({
$or: [{ from: user }, { to: user }],
});
}
// ... (rest omitted)
}
Concept Reuse with MongoDB
Since concepts are generic, we can also reuse them very easily. For example, let’s say we have a Commenting
concept and we allow commenting on Post
. Inside CommentDoc
, we should have a field target: ObjectId
(instead of post: ObjectId
, since target
is more generic). We then decide that we want to allow commenting on User
(i.e., making a comment about a user), so we can just reuse the comment concept.
In fact, you could first try to use a single instantiation for both posts and comments, like this:
const CommentOnPostOrUser = new CommentingConcept("comments");
But that’s not good practice. We don’t want to mash up data that belongs to different places into the same collection. Let’s try to separate them by simply instantiating the concept twice:
const CommentOnPost = new CommentingConcept("comments");
const CommentOnUser = new CommentingConcept("comments");
This won’t work as intended: in the database, we still don’t have any separation between comments on users vs posts, so it would be really hard to maintain this structure. We need to create different MongoDB collections for the two concept instances:
const CommentOnPost = new CommentingConcept("comments_on_posts");
const CommentOnUser = new CommentingConcept("comments_on_users");
Now the two instances of Commenting
are operating independently.
Actions and Syncs into Web APIs
In the beginning of this document, you learned how to write web routes that work with simple functions. Later, you learned how to implement concepts actions or synchronizations which are also just functions. Now, it’s time to combine both!
For example, this is how we would create a couple of routes for getting and creating users:
class Routes {
@Router.get("/users")
async getUsers() {
return await Authing.getUsers();
}
@Router.get("/users/:username")
@Router.validate(z.object({ username: z.string().min(1) })) // declarative validation, discussed below!
async getUser(username: string) {
return await Authing.getUserByUsername(username);
}
@Router.post("/users")
async createUser(session: SessioningDoc, username: string, password: string) {
Session.isLoggedOut(session);
return await Authing.create(username, password);
}
}
Here’s how we would implement a route for sending a friend request:
@Router.post("/friend/requests/:to")
async sendFriendRequest(session: SessionDoc, to: string) {
const toOid = new ObjectId(to);
await assertUserExists(toOid);
const user = Sessioning.getUser(session);
return await Friending.sendRequest(user, toOid);
}
As you can see, to
is expected from the URL parameter, and we synchronize 3 actions: make sure that such to
user exists, get the current user from the session, and send the friend request from user
to to
.
As an example of another sync, let’s say I want my app to make a post every time I log in. Here’s how I might do it:
@Router.post("/login")
async login(session: SessionDoc, username: string, password: string) {
const user = await Authing.authenticate(username, password);
Session.start(session, user._id);
return await Posting.create(user._id, "Hi, I just logged in!");
}
Keep in mind that routes are usually expected to be atomic — so, make sure to do validations first so you don’t run into unintended side effects.
Notice that we must convert object IDs received as string
parameters into ObjectId
instances in order to use them with MongoDB.
Since the MongoDB API functions will not always provide static type checking for object IDs (for example, in a query filter), you must take care to remember where ObjectId
s are needed.
Response Formatting
Since concepts are generic, the data they return will sometimes contain information that the front-end user cares less about. For example, let’s look into this route:
@Router.get("/posts")
async getPosts() {
return await Post.getPosts();
}
Note that this will return an array of PostDoc
, and the author
field inside them is ObjectId
. While the front-end developer could make another request for converting those into usernames using another route (e.g., getUsernames(ids: ObjectId[]): string[]
), it’s a better idea to solve this in the server side to decrease the amount of complexity in the front-end. For this, we implement Response
handlers in responses.ts
that reformat responses. For example:
export default class Responses {
/**
* Convert PostDoc into more readable format for the frontend
* by converting the author id into a username.
*/
static async post(post: PostDoc | null) {
if (!post) {
return post;
}
const author = await Authing.getUserById(post.author);
return { ...post, author: author.username };
}
/**
* Same as {@link post} but for an array of PostDoc for improved performance.
*/
static async posts(posts: PostDoc[]) {
const authors = await Authing.idsToUsernames(
posts.map((post) => post.author)
);
return posts.map((post, i) => ({ ...post, author: authors[i] }));
}
}
and then we can do
@Router.get("/posts")
async getPosts() {
return await Responses.posts(await Post.getPosts());
}
Declarative Validation
As you create more routes in routes.ts
, you can utilize declarative validation to constrain parameter types, rather than writing procedural code that explicitly checks the parameters in the function body. We use the TypeScript type validation library Zod for this feature of our framework.
For example, after decorating getUser
as a GET route with a username parameter:
@Router.get("/users/:username")
We declaratively define a validation scheme for the username, mandating that the parameter be a string with length of at least 1 character:
@Router.validate(z.object({ username: z.string().min(1) }))
Thus, if the parameter does not satisfy these conditions, the request will fail validation and prevent the route handler from being executed. This prevents invalid data from being processed before the body code is executed, making your code more safe from bugs and easier to understand as you don’t have to repeat if...else
or try...catch
blocks within function bodies.
If the validation is successful, your handler function executes as expected. Yay!
async getUser(username: string) {
return await Authing.getUserByUsername(username);
}
Error Formatting
In concepts, we would like to avoid having web parts as much as possible. However, it would be cumbersome to catch every different type of error in routes.ts
and handle them separately. Thus, we provide error templates in concepts/errors.ts
you can directly use or extend from that will automatically be sent to the front-end by the framework. For example, here’s a usage of it:
// in AuthenticatingConcept class
async authenticate(username: string, password: string) {
const user = await this.users.readOne({ username, password });
if (!user) {
throw new NotAllowedError("Username or password is incorrect.");
}
return { msg: "Successfully authenticated.", _id: user._id };
}
If you try to log in with the wrong username or password, you’ll get a status code of 403 and the following JSON as a response.
{
"msg": "Username or password is incorrect."
}
Now, same as we saw earlier with response formatting, we would like to have formatting for errors as well. For example, the error might say ${author} is not the author of post ${id}
. In this case, author
will be ObjectId
, but we would like to convert it into a username for so the frontend is happy. For this, we define our own error where we can access author
and id
separately:
// in concepts/posting.ts
export class PostAuthorNotMatchError extends NotAllowedError {
constructor(public readonly author: ObjectId, public readonly _id: ObjectId) {
// {0} and {1} will be replaced by corresponding arguments
super("{0} is not the author of post {1}!", author, _id);
}
}
Then in the action, we can use this error like this:
// inside PostingConcept
async assertAuthorIsUser(_id: ObjectId, user: ObjectId) {
const post = await this.posts.readOne({ _id });
if (!post) {
throw new NotFoundError(`Post ${_id} does not exist!`);
}
if (post.author.toString() !== user.toString()) {
throw new PostAuthorNotMatchError(user, _id);
}
}
Now that we can access author
and _id
fields separately in PostAuthorNotMatchError
, we can also format it. To do this, in responses.ts
, we register the error for handling:
Router.registerError(PostAuthorNotMatchError, async (e) => {
const username = (await Authing.getUserById(e.author)).username;
return e.formatWith(username, e._id); // replace first arg with username
});
This will magically handle an error of this type and convert the user id in the field e.author
to a username
!
Here are some key points to keep in mind:
- Use
_id
when referring to id of a specific document in MongoDB collection. - When comparing two
ObjectId
types in TypeScript, always make sure to usetoString
like_id1.toString() === _id2.toString()
. Inside a MongoDB filter, make sure theObjectId
is actually anObjectId
type — otherwise, it will return no results. - If the action is used as a “validator,” prefix its name with
assert
(e.g.,assertAuthorIsUser
). - If the action has a side effect (making changes to the database), include a
msg
property in your response. This way, you can also directly show thesemsg
fields in your frontend (e.g., as a pop-up) without having to do additional work! Keep in mind that all errors also have amsg
field. - In routes, URL parameters and query properties must be
string
s. The request body can contain other JSON types like numbers and arrays.
Unit Testing
Now that you’ve developed your application, a helpful way to test its behavior to ensure that it functions the way you expect is by writing your own unit tests. Unit testing involves verifying that individual parts of your code behave correctly by testing a part in as much isolation as possible.
We automate unit testing by providing test templates in test/test.ts
where you can continue to add your own tests. To write these automated unit tests, we use Mocha.
The tests are structured with a call to it()
, with the first argument as a string
representing the test’s name and the second argument as a function
expression representing the body of the test. You wrap several it()
function calls in a describe()
call to contain your unit tests within a test suite.
For example, the starter code creates one test suite that is focused on login behavior. We create a test suite:
describe("Create a user and log in", () => { ... });
Within the test suite, we use multiple it()
calls to cover a range of expected behaviors related to log in. To name a couple, this includes successful user creation and log in:
it("should create a user and log in", async () => {
const session = getEmptySession();
const created = await app.createUser(session, "barish", "1234");
await assert.rejects(app.logIn(session, "barish", "123"));
await app.logIn(session, "barish", "1234");
});
and throwing an error when trying to create a username that already exists:
it("duplicate username should fail", async () => {
const session = getEmptySession();
const created = await app.createUser(session, "barish", "1234");
await assert.rejects(app.createUser(session, "barish", "1234"));
});
For more on automated testing and advice on how to ideate and choose test cases, feel free to take a look at the relevant reading on testing from 6.102.
A bit of engineering philosophy
Modularity and separation of concerns are one of the most important ideas of excellent engineering work. Of course, sometimes modularity has to be partially sacrificed for performance, but poor modularity leads to so many problems that big companies (Google, Microsoft, etc., and even trading companies) work hard to achieve as much modularity as possible. This is because coupling exponentially increases tech debt and makes it harder to maintain the code base and its separate elements. For example, if we kept comments inside posts, then we would be forcing some business logic in our data types – if you delete a post, comments now must be deleted. That would rule out the common behavior of some apps (e.g., Reddit) that allow independent deletion of posts and comments.
The way we implement concepts separates them from the business logic (unless we really want to bake that logic inside the concept). For example, we can choose to delete or keep comments on post deletion. Or if we decide that there are now User Roles in our app and an admin
role can also edit other’s posts, we don’t need to touch any existing concept code! We just implement a new concept and modify a few lines in updatePost
route. I personally find that super cool!
Another very important principle, as you know from 6.102 is the importance of testing. Due to work load, we are not enforcing unit tests in your implementations, however, notice how easy it is to test both individual concepts and our synchronizations between them. They are just normal functions you can call in your code, without simulating any kind of network requests (remember tests from Memory Scramble in 6.102 — you had to start the server and do fetch
).
To sum up, we hope this is an elegant way to implement concepts and web routes in TypeScript while making sure they are modular and the concerns are separated. If you have any suggestions, or come up with a better way, please chat with us!
Resources
Daniel’s concept tutorials: https://essenceofsoftware.com/tutorials/
Lecture and Recitation notes: https://61040-fa24.github.io/schedule
Additional notes on data modeling and concept modularity
MongoDB reference for Node.js (especially the fundamentals part): https://www.mongodb.com/docs/drivers/node/current/