DEV Community

Cover image for Going Back to TypeScript Basics
Sabi Mantock
Sabi Mantock

Posted on

Going Back to TypeScript Basics

After finishing my JavaScript refresher, TypeScript was next.

I’ve used TypeScript before, so I went into this thinking it would mostly be a case of reminding myself of things I already knew.

That was partly true.

The basic stuff came back pretty quickly. Types on variables, functions, arrays, objects — nothing too surprising there.

const memberCount: number = 10;

function isAdmin(role: string): boolean {
    return role === "admin";
}
Enter fullscreen mode Exit fullscreen mode

But as I went further, I started noticing the same thing that happened during my JavaScript refresher.

There’s a difference between recognising something and actually understanding it.

I could look at a generic and think, yeah, I know what that is. But could I write one myself? Could I explain why I needed it? Could I tell when I didn't need one?

Not always.

And that’s really why I wanted to do this refresher in the first place.

TypeScript started making more sense when I stopped fighting it

One thing that became clearer this time was type narrowing.

Say I have:

interface Member {
    name: string;
    profilePicture: string | null;
}
Enter fullscreen mode Exit fullscreen mode

I can't just do:

member.profilePicture.toUpperCase();
Enter fullscreen mode Exit fullscreen mode

because TypeScript doesn't know whether profilePicture is actually a string.

My first instinct in the past would've probably been something along the lines of, I know it's a string here, why are you complaining?

But that's the point.

I might know what I expect the value to be. TypeScript wants me to prove it.

if (member.profilePicture !== null) {
    member.profilePicture.toUpperCase();
}
Enter fullscreen mode Exit fullscreen mode

Once I started looking at narrowing like that, it stopped feeling like TypeScript getting in my way.

The same thing happened with .find().

function findMember(
    members: Member[],
    name: string
): Member | undefined {
    return members.find((member) => member.name === name);
}
Enter fullscreen mode Exit fullscreen mode

I might expect the member to exist, but .find() doesn't care about my expectations. It can return undefined.

So the type should say that.

Simple, but important.

Generics weren't as scary as they looked

Generics were another one.

This:

function getLast<T>(items: T[]): T | undefined {
    return items[items.length - 1];
}
Enter fullscreen mode Exit fullscreen mode

used to look more complicated than it actually is.

Once I started thinking of T as basically saying “whatever type you give me”, it became much easier to read.

Give it strings?

T is a string.

Give it numbers?

T is a number.

Give it members?

T is a Member.

Then we got to things like:

function getPostValue<K extends keyof Post>(
    post: Post,
    key: K
): Post[K] {
    return post[key];
}
Enter fullscreen mode Exit fullscreen mode

Now, I'll admit, something like this still looks a bit mad when you first see it.

But breaking it apart helped.

keyof Post gives me the valid keys from Post.

K is one of those keys.

Post[K] is the type of whatever property I asked for.

So if "title" points to a string, TypeScript knows I'm getting a string back. If "likes" points to a number, I get a number.

It wasn't really about memorising the syntax. It was understanding what each part was trying to describe.

I managed to confuse Pick and Omit

We also went through TypeScript's utility types: Partial, Pick, Omit and Record.

Most of it was straightforward.

Then I got asked something like:

If you only want title, company and salary from a Job type, which utility type would you use?

I said Omit.

My reasoning?

Because I only wanted title, company and salary.

Which is literally the reason I should have used Pick.

😂

That mistake actually made the difference easier to remember than just reading the documentation would have.

Pick what you want to keep.

Omit what you want to remove.

Sometimes getting something wrong once is all it takes.

Then things started connecting

Discriminated unions were where TypeScript started feeling less like “adding types to JavaScript” and more like actually modelling how an application works.

For example:

type MemberState =
    | { status: "loading" }
    | { status: "success"; members: Member[] }
    | { status: "error"; message: string };
Enter fullscreen mode Exit fullscreen mode

If the status is "success", TypeScript knows there are members.

if (state.status === "success") {
    console.log(state.members);
}
Enter fullscreen mode Exit fullscreen mode

If it's "error", TypeScript knows there's a message.

And if it's "loading", neither of those things should exist yet.

That makes sense because that's how the application actually behaves.

We also looked at never for checking that every possible state has been handled.

That was when I started seeing that a lot of these TypeScript features aren't really separate things.

They build on each other.

Union types lead to narrowing.

Narrowing makes discriminated unions useful.

Discriminated unions make exhaustive checking possible.

The individual syntax started mattering less than the relationship between everything.

Type predicates were another good example

We wrote things like:

function isAdmin(user: User): user is Admin {
    return user.role === "admin";
}
Enter fullscreen mode Exit fullscreen mode

The interesting bit for me was:

user is Admin
Enter fullscreen mode Exit fullscreen mode

We're not just returning true or false.

We're telling TypeScript:

If this function returns true, I've checked this value and you can treat it as an Admin.

That's powerful, but there's also a catch.

TypeScript trusts you.

If the function says user is Admin but my actual logic checks for a member instead, TypeScript isn't going to magically save me.

I'd basically be lying to the compiler.

That distinction between what TypeScript can check and what my code still needs to get right came up again later.

API data made unknown click for me

This was probably one of the most useful parts of the refresher.

Imagine I do this:

const data: Member[] = await response.json();
Enter fullscreen mode Exit fullscreen mode

It looks nice.

My editor is happy.

TypeScript thinks I have an array of members.

But what if the API sends something completely different?

Writing Member[] doesn't magically transform the response into valid member data.

I'm basically just telling TypeScript to trust me.

A safer starting point is:

const data: unknown = await response.json();
Enter fullscreen mode Exit fullscreen mode

Now I have to prove what the data actually is before using it.

We ended up building a validator manually:

function isMember(value: unknown): value is Member {
    if (typeof value !== "object" || value === null) {
        return false;
    }

    if (!("name" in value) || typeof value.name !== "string") {
        return false;
    }

    if (!("id" in value) || typeof value.id !== "number") {
        return false;
    }

    if (
        !("role" in value) ||
        (value.role !== "admin" && value.role !== "member")
    ) {
        return false;
    }

    if (
        !("isOnline" in value) ||
        typeof value.isOnline !== "boolean"
    ) {
        return false;
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

And, of course, I got some of the logic wrong at first.

I was doing something similar to:

if (("id" in value) && typeof value.id === "number") {
    return true;
}
Enter fullscreen mode Exit fullscreen mode

In my head I was checking that the id was valid.

Which I was.

The problem was that I was then returning true for the entire object.

So apparently having a valid ID was enough to become a valid member. Name? Role? Online status?

Who needs those? 😂

The better way was:

if (!("id" in value) || typeof value.id !== "number") {
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Check each reason the object could be invalid, reject it when something is wrong, and only return true once it survives all the checks.

That wasn't even really a TypeScript problem.

It was a logic problem.

And that's exactly the kind of thing I wanted this refresher to expose.

By the end, the types were doing something useful

Towards the end, I had to model members and admins.

My first attempt was:

type Role = "admin" | "member";

interface Member {
    id: number;
    name: string;
    role: Role;
    isOnline: boolean;
}

interface Admin extends Member {
    permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

Looks reasonable.

Except there's a problem.

Because Admin inherits role: Role, this is technically possible:

const admin: Admin = {
    id: 1,
    name: "Sabi",
    role: "member",
    isOnline: true,
    permissions: ["delete-post"]
};
Enter fullscreen mode Exit fullscreen mode

An admin whose role is "member".

Not exactly ideal.

So we changed the model:

interface BaseUser {
    id: number;
    name: string;
    isOnline: boolean;
}

interface Member extends BaseUser {
    role: "member";
}

interface Admin extends BaseUser {
    role: "admin";
    permissions: string[];
}

type User = Member | Admin;
Enter fullscreen mode Exit fullscreen mode

Now an Admin has to be an admin.

A Member has to be a member.

Then I could write:

function getOnlineAdmins(users: User[]): Admin[] {
    return users.filter(
        (user) => user.role === "admin" && user.isOnline
    );
}
Enter fullscreen mode Exit fullscreen mode

It's a small example, but I think it sums up what I've taken from this refresher.

Before, I probably would have looked at TypeScript mainly as a way of saying:

name: string
age: number
isOnline: boolean
Enter fullscreen mode Exit fullscreen mode

That's part of it.

But good types can describe much more than that.

They can describe what states are possible, what data might be missing, what kind of user you're dealing with, what a function actually returns and what your application should and shouldn't allow.

So, do I know TypeScript now?

More than I did before.

Everything? Definitely not.

There are still advanced parts of TypeScript that I haven't touched, and I'm sure I'll run into plenty of things that make me stare at my screen wondering what on earth I'm looking at.

But I don't think I need to know all of TypeScript before moving forward.

That wasn't the point.

The point was to get comfortable enough with the fundamentals that when I start using TypeScript properly with React, I'm not just adding types until the red lines disappear.

More importantly, I'm starting to understand what TypeScript is trying to tell me when those red lines appear.

JavaScript refresher done.

TypeScript refresher done.

Now, React.

Top comments (0)