How 2 Code Gud (with TypeScript)

Are you a struggling student of one of Mr. Whalen's Computer Science classes in room C56 at Staten Island Technical High School, located at 40°34'05.9"N 74°06'59.6"W (40.568306°, -74.116560°)? Damn that must suck

By literally god (Kenneth Ng)

22 minute read | Published February 7, 2025

How 2 Code Gud (with TypeScript)

Introduction

To TypeScript or Not to TypeScript?

Yes. If you hate TypeScript because of the errors, that's the whole point of it.

"Wahhhh I'm a wittle baby and I hate the red squiggly lines" -> Don't write shit code.

"My code isn't shit, TypeScript just hates me wahhhhhhhhh" -> Yes it is.

Types vs Interfaces

Types and Interfaces both do the same things, but with a few differences.

You may have heard from a certain teacher that starts with M and rhymes with Ichael Whalen that interfaces are better than types. Why? Because "everyone else in the class uses interfaces."

Stop being a sheep and start thinking for yourself. If you start digging, you'll come across these two main arguments in favor of interfaces:

  1. Interfaces compile faster
  2. Interfaces can extend other interfaces

The first point is completely wrong. While it may have been true in 2013 when we were all running shitboxes, in today's day and age, we use an entire bottle of water to run 100 words through ChatGPT. Point being, not only have most of us had our brains develop since 2013, our computers have done the same thing.

The computers of today are not like those of 2013; the difference in compile time is milliseconds.

Now the second point is valid; if you need to extend another type, the correct way to do it is with interfaces. (You could do it with types, but meh)

Take this example:

interface Window {
  opacity: number;
}
interface ExtraCoolWindow extends Window {
  coolness: number;
}

const myWindow: ExtraCoolWindow = {
  opacity: 0,
  coolness: 9001
};

That all works fine and dandy, right? We just created two interfaces, one if which extends the other. What's the problem? Why wouldn't this work as intended? Why are you creating so much suspense? Stop stalling bruh just tell us the answer 💀💀💀💀💀💀

Let's look at why interfaces are HORRIBLE, DISGUSTING, and PROBABLY COMMIT ARSON TO ORPHANAGES:

  1. Interfaces automatically merge with each other
  2. Interfaces are extremely limited
  3. Interfaces are ugly 🎉

The first point: interfaces automatically merge with each other. What does this mean?

interface User = {
  quandleDingle: string;
}
interface User = {
  skibidiSigmaHuzz: () => Promise<Partial<Record<number, boolean[]>>>;
}

const myUser: User = {
  skibidiSigmaHuzz: async () => new Promise((resolve) => resolve({}));
}

From the face of it, this looks extremely wrong, right? These two user types are completely unrelated, and therefore, should be completely different types.

However, since we're using interfaces, these two interfaces will combine into 1 interface. myUser will throw an error, saying quandaleDingle is not defined. Is that the behavior you intended? Absolutely fucking not. If you didn't know interfaces behaved like this, would you be confused? Absolutely.

Okay, I hear you saying: "but the interfaces are right there, I can SEE that the interfaces have the same name. I would never create an interface with the same name?? I'm not dumb 🙄🙄🙄🙄🙄✋"

Firstly, if the interfaces were buried in a 1,000 line file (which they often are), you won't see the interfaces. And because the error is thrown when you USE the interface, and not when you create it, you won't know until 2 hours later when you stop watching YouTube, at which point you'll forget that you created the interface in the first place.

You don't even need to accidentally merge your own interfaces for automatic merging to become an issue.

Because all 3,000+ of the default DOM types (such as Window) are interfaces, if you accidentally create an interface with the same name as one of the 3,000+ default DOM interfaces, you'll be merging with them as well.

Secondly, this is the same argument as "don't use TypeScript, just code perfectly." The whole REASON we use TypeScript is to avoid easily made mistakes, so why are we INTENTIONALLY making ourselves more prone to mistakes? If you like making silly mistakes, go back to plain JavaScript like the psychopath you are!

Thirdly, yes you are.

Okay, now the second point: Interfaces are extremely limited.

Types can do everything interfaces can do, and more. We use types for a lot of things, so it's inevitable we'll end up using both types AND interfaces in our project. We should try to avoid mixing the two in our projects, since if we do, a new project member may get decision paralysis when wanting to create a new type/interface. It's also just better to have consistent standards, because consistency ✨

Now the third point: Interfaces are ugly. Yeah, they just are. This is an objective fact and you cannot prove otherwise. If you have any grievances, please send them to idontcare@yahoo.com - I would love to read them.

Let's improve all of the code in this section by simply using types instead of interfaces:

// doesn't merge with the DOM Window interface ✅
type Window = {
  opacity: number;
};
type ExtraCoolWindow = Window & {
  coolness: number;
};

const myWindow: ExtraCoolWindow = {
  opacity: 0,
  coolness: 9001
};

Why TypeScript is Great

Consider the following scenario: you're assigned to a project nobody has worked on in 5 years, and the people who worked on it had no idea what they were doing. Wild scenario, would never happen in real life!

You come across the following code:

const userStore = useUserStore();
const user = userStore.user;

const civilization = await getCivlization();

await sendToThePresident(civilization.phrase); // very important function that needs to work

Well, that's pretty fucking vague. What's a civilization? Maybe we could take a look at getCivlization and see what it returns.

async function getCivilization(user) {
    const response = await fetch("/backend", body: user);
    return await response.json();
}

Well that's fucking useless. And wait a second, this function requires a parameter? Does the code in index.js even work? Holy shit it's 3am I don't want to look at this fucking code anymore 💀💀💀

Now, let's imagine the person who wrote this project included TypeScript in their shit code (and imagine they knew how to use TypeScript properly):

const userStore = useUserStore();
const user = userStore.user;

const civilization: ParkourCivilization | GitHubCivilization = await getCivlization(user);

await sendToThePresident(civilization.phrase); // very important function that needs to work

Wow, look at that! I know exactly what civilization is and what it contains, and I don't even need to look at the getCivilization function! What a miracle.

And hey, would you look at that? The person who wrote this code 5 years ago remembered to add the user parameter to the getCivilization function - because of TypeScript! TypeScript threw an error when they forgot to include user, so they saw it and fixed it.

TypeScript helps you avoid silly mistakes in your code, and helps other people fix your code 5 years down the line. Why would you purposely make your code harder to work with?

Bad TypeScript is worse than no TypeScript.

A project with horrendous TypeScript will be worse to work with than a vanilla JS project. When you write your project, write it correctly!

Naming Conventions

While not absolutely necessary, a good Vue project will have the proper naming conventions.

CaseUse Cases (in general)
PascalCaseComponents, Types, Interfaces, Classes
camelCasevariableNames, functionNames, fileNames.ts
kebab-caseclass-names, id-names, anything-html-really
snake_casenever
fuckmeits3amfuckthisprojectnever

Variables

Variables should have a concise, descriptive name. Do not make one word variables, except in "temporary" situations, like in a for loop.

const x: string[] = []; // ❌ what the fuck is x?
const fucksToGive: string[] = []; // ✅ much better

for (let i in fucksToGive) // this is fine
for (const fuck of fucksToGive) // ✅ but `for of` is better

Types and Interfaces

Never pluralize the names of types or interfaces, unless they're array types themselves.

export type Users = {
  isMewing: boolean;
};

This is for clarity reasons - you might think Users would be an array type, but if your dumbass didn't make it an array type, you'll look like an idiot.

export type User = {
  isMewing: boolean;
};
export type Users = User[];

JSDoc

JSDoc is a wonderful tool someone took time out of their day and invented for you to document your code. Don't let their hard work go to waste; use it!

You can add it to your types:

export type Person = {
  /** Represents the person's gyatt, if skibidi or ohio. */
  gyattOhioSkibidi?: Gyatt;
};
export type Gyatt = {
  basedFanumTaxAuraDate: string;
};

Now, whenever you use it in your code, you can hover over the parameter and see what it means!

function findSkibidi(person: Person) {
  return person.gyattOhioSkibidi.basedFanumTaxAuraDate;
}

Notice how if you hover over basedFanumTaxAuraDate, you don't get any information about it?

You can assume it's a date by its name. But wait, it's a string! In that case, it must be a date string, but... there's 5 million ways to represent a date as a string! Which is it??? Too bad you can't ask the person who wrote this code, because they left this school/company 20 years ago!

Save yourself, and others, the mental breakdown. Add JSDoc comments to your code!

export type Gyatt = {
  /** Date of the gyatt's +1000 aura increase, in YYYY-MM-DD format.
   * @example "2007-12-08"
   */
  basedFanumTaxAuraDate: string;
};

You might think to yourself: I'm the only person who will ever read this code, why should I?

You'll finish your project and move on, but in a few months, you'll want to add something you missed! But uh oh, you forgot about everything in your project! Without JSDoc, you'll have to skim through every fucking file to find what you're looking for.

Guard Clauses

Nested if statements are the literal devil. Would you invite the devil into your home? No!

function findCap(statement: string | undefined) {
  if (statement) {
    if (statement.length > 0) {
      if (statement.length <= 100) {
        if (pastStatements.includes(statement)) {
          return "cap";
        } else {
          return "no cap";
        }
      } else {
        return "cap";
      }
    } else {
      return "SUPER cap";
    }
  } else {
    return "cap";
  }
}

Can you find the if statement that returns SUPER cap? If it takes you more than 1 second, it's shit code.

Instead of nesting shitloads of if statements inside each other, we can take each one out and, instead of checking if value, we can invert our checks and check if not value:

function findCap(statement: string | undefined) {
  if (!statement) {
    return "cap";
  }
  if (statement.length <= 0) {
    return "SUPER cap";
  }
  if (statement.length > 100) {
    return "cap";
  }
  if (pastStatements.includes(statement)) {
    return "cap";
  }
  return "no cap";
}

One-Liner If Statements

In the above example, we turned literal dogshit code into readable code. But you can always unshitify more!

All of the if statements in the readable code only have 1 line in them, but they take up 3 lines! That's inefficient, and tbh, ugly as fuck! Let's take advantage of JavaScript's one-liner if statements:

function findCap(statement: string | undefined) {
  if (!statement) return "cap";
  if (statement.length <= 0) return "SUPER cap";
  if (statement.length > 100) return "cap";
  if (pastStatements.includes(statement)) return "cap";
  return "no cap";
}

Now we're getting somewhere! But let's consider this next example:

function findCap2(statement: string | undefined) {
  if (!statement) {
    isCap.value = true;
    return;
  }

  if (statement.length <= 0) {
    isCap.value = true;
    return;
  }
  if (statement.length > 100) {
    isCap.value = true;
    return;
  }
  if (pastStatements.includes(statement)) {
    isCap.value = true;
    return;
  }
  isCap.value = false;
  return;
}

There's 2 lines in each if statement! We can't possibly use one-liners if there's two lines, right?

Well, we can take advantage of another trick with JavaScript one-liner if statements:

function findCap2(statement: string | undefined) {
  if (!statement) return (isCap.value = true);
  if (statement.length <= 0) return (isCap.value = true);
  if (statement.length > 100) return (isCap.value = true);
  if (pastStatements.includes(statement)) return (isCap.value = true);
  return (isCap.value = false);
}

When you use this trick, know that it may change the return value of the function.

useRouter().push and useRouter().replace actually return something called a NavigationFailure, so if you return those, the function can return a NavigationFailure instead of void or whatever your function originally returned.

Ternary Operator

Have you ever needed to assign a value to a variable based on a condition? Happens all the time. You might come up with the following:

let pookieBear = "";

if (isPickMe) pookieBear = "skibid";
else pookieBear = "skibi";

But that takes up 4 lines! 3 if you don't care about indents! We can use a ternary operator to accomplish all of this in a single line:

const isPookie = isPickme ? "skibid" : "skibi";
/* Translation:
if isPickMe, return the first value
else, return the second value
*/

We also get the added benefit of being able to make the variable a const instead of let.

Don't get carried away and create 100-chained ternaries. It's just as bad as a 100-nested if statement!

This is common practice for ensuring job security, but not good for writing code you'd be proud of.

Whenever you do this, it is common etiquette to include a good luck comment to the next person that reads your code.

// good luck lil bro
// my job security is 📈📈📈📈📈📈
const demureLevel = 
  mewing >= 5 ?
  isCooking ?
  mogger !== pogger ?
  copium <= hopium && hopium !== mewing ?
  glazer || iceSpice || goofy % ahh !== 5
  ? isCrashout ? 10
  : 9
  : 7
  : 2
  : 4
  : 0
  : 0;

Components

You can use components to abstract your code away into different places to make your file easier to read.

<template>
  <div class="...tailwind shit" :class="'...more tailwind shit'" @click="showMenu = false" v-if="isSimping">
    <div v-if="friesInTheBag !== 0" class="...tailwind shit" :class="'...tailwind shit'">
      <button
        v-for="smth in data"
        :key="smth.id"
        class="...tailwind shit"
        :class="'...tailwind shit'"
        type="button"
        @click.stop="showMenu = !showMenu"
      >
        Open WcDonalds App
      </button>
    </div>

    <div v-else>
      <p>Just put the fries in the bag {{ isLilBro ? "lil bro" : "gang" }}</p>
    </div>
  </div>
</template>

Can you understand what the fuck is going on here? Maybe, but once it gets complicated enough, you'll have to put on your grandma glasses to understand your code. Let's avoid that!

By using a component, we can abstract part of this code away from the main page file and make it much more readable:

<template>
  <div class="...tailwind shit" :class="'...more tailwind shit'" @click="showMenu = false" v-if="isSimping">
    <WcDonalds v-if="friesInTheBag !== 0" :data="data" @toggle-menu="showMenu = !showMenu" />

    <div v-else>
      <p>Just put the fries in the bag {{ isLilBro ? "lil bro" : "gang" }}</p>
    </div>
  </div>
</template>

Now that we've split the code into 2 parts, we can easily differentiate between the WcDonalds app and the main page.

Don't go too overboard, though! Too much component abstraction can make your code incredibly hard to navigate, and will require shit loads of props and emits:

<template>
  <WcDonalds
    @click="showMenu = false"
    :data="data" 
    :fries-in-the-bag="friesInTheBag" 
    :is-lil-bro="isLilBro" 
    @toggle-menu3="showMenu = !showMenu"
  />
</template>

Not only will nobody read your code, but you won't read your code, either!

Explicit Typing

When you first discover explicit typing, you may have the sudden masculine urge to explicitly type everything. But hold on, bucko! Once you finish doing that, your code will look something like this:

<script setup lang="ts">
const isYikes: Ref<Boolean> = ref<Boolean>(true);
const bussin: Ref<String> = ref<String>("nope");
const caughtIn4k: Ref<Number> = ref<Number>(4000);
const deluluIsntTheSolulu: Ref<bigint> = ref<bigint>(696969696n);
const looksmaxxingOrNah: Ref<"yuh" | "nah"> = ref<"yuh" | "nah">("nah");
const gooner: Ref<Person | undefined> = ref<Person | undefined>();
const ipadKidList: Ref<Array<Person>> = ref<Array<Person>>([]);
const ipadKidsFiltered: Array<Person> = ipadKidList.value.filter((ipadKid: Person) => ipadKid.isDumb);

function finnaSkillIssueU(person: Person | null | undefined): boolean | null | undefined {
  // null and undefined are different things. Don't get them mixed up
  return true;
}
</script>

This is, frankly, ugly as fuck! Ain't nobody reading your code lil bro!

NEVER use String, Number, or Boolean to declare a type.

String, Number, and Boolean are constructors for strings, numbers, and booleans, respectively, i.e. new Boolean(value). To declare a type, use their lowercase counterparts: string, number, and boolean.

NEVER explicitly define something, unless it is something complex, like an array, object, or union type.

TypeScript is not dumb; it's pretty smart, actually. Probably smarter than you. Alot of things can be inferenced without an explicit declaration.

Here is some better code:

<script setup lang="ts">
const isYikes = ref(true); // the Ref will infer the type of its initial value
const bussin = ref("nope");
const caughtIn4k = ref(4000);
const deluluIsntTheSolulu = ref(696969696n);
const looksmaxxingOrNah = ref<"yuh" | "nah">("nah"); // explicit type is necessary here because its a union type
const gooner = ref<Person>(); // you dont need the "| undefined", the Ref will infer it if the initial value is empty
const ipadKidList = ref<Person[]>([]); // use Type[] instead of Array<Type> to declare an array type
const ipadKidsFiltered = ipadKidList.value.filter((ipadKid) => ipadKid.isDumb); // the type of ipadKid is automatically inferred from ipadKidList

function finnaSkillIssueU(person: Person | null | undefined) {
  // no explicit return type is necessary because TypeScript will infer based on the return value of the function
  return true;
}
</script>

How to Subvert TypeScript

Have you ever tried assigning a value to something, but TypeScript has yelled at you? Usually, it's because of a valid reason, in which case you should not subvert the TypeScript compiler (TSC) because it means your code may be shit.

In some cases, however, you know more than the compiler. In these cases, TypeScript is a bitch and you may have the urge to unplug its soul.

Consider this example:

export interface Rizzler {
  rizz: number;
}
export interface SuperRizzler extends Rizzler {
  superRizz: number;
}

TypeScript will give you a "userStore.rizzler.superRizz does not exist on Rizzler" error. While it's not wrong, it doesn't know the full context of your application; it has no idea the page is only accessible to SuperRizzlers and not Rizzlers. Because you defined userStore.rizzler as being SuperRizzler OR Rizzler, that's what it's going off of.

So, how do you fix this? Do you just remove TypeScript from your project? Maybe, but here are some more elegant solutions:

Method 1: Use as

<script setup lang="ts">
const userStore = useUserStore();
const rizz = ref(0);

onMounted(() => {
  rizz.value += userStore.rizzler.rizz + (userStore.rizzler as SuperRizzler).superRizz;
});
</script>

By using as, you can tell TypeScript to assume the value to be of SuperRizzler type.

This is a pretty powerful tool, but with great power, comes great responsibility. Take this example:

let sigma: number | undefined;

function getSigma(input: number): number {
  return (sigma as number) + number;
}

In this example, sigma is either number or undefined. However, when we coded this function, it was 3am and we couldn't give any more shits as to our code quality, so we used as to tell TypeScript that sigma is only a number.

To the naked eye, this looks fine. TypeScript won't give any errors, so it must be fine. When we use this function 3 months later, however, we'll notice something odd: it's... NaN? Why is it NaN? It says it returns a number!!

Well, because sigma is undefined, and undefined + any number = NaN, this function returns NaN in this case. But why didn't TypeScript pick it up? Because you told it that sigma is never undefined!

In this case, we would not use as, but instead a ?? (nullish coalescing) or || operator.

let sigma: number | undefined;

function getSigma(input: number) {
  return (sigma ?? 0) + input;
  /* Translation:
    If sigma is defined, return sigma + input.
    If sigma is not defined, return 0 + input.
    */
}

Method 2: Use in

<script setup lang="ts">
const userStore = useUserStore();
const rizz = ref(0);

onMounted(() => {
  rizz.value += userStore.rizzler.rizz;
  if ("superRizz" in userStore.rizzler) rizz.value += userStore.rizzler.superRizz;
});
</script>

The in operator will check if superRizz is a parameter of userStore.rizzler. Since superRizz only exists in SuperRizzlers and not Rizzlers, TypeScript will know that userStore.rizzler will be of type SuperRizzler within this if statement.

*I'm fairly confident but don't quote me on it

Method 3: Bloat Your Types/Interfaces

export interface Rizzler {
  userType: "rizzler";
  rizz: number;
}
export interface SuperRizzler extends Rizzler {
  userType: "super rizzler";
  superRizz: number;
}

This accomplishes the same thing as using in, but you will need to edit your types/interfaces.

Method 4: Separate your data

const rizzler = ref<Rizzler>();
const superRizzler = ref<SuperRizzler>();
async function init() {
    const response = await fetch("/backend", body: user);
    const data = await response.json();
  ... // do some logic to determine whether to assign data to rizzler or superRizzler, or do the logic on the backend
}

This comes with the downside of having to separate your user into 2 variables.

Type Generics

It sounds scary. But it's not. Let's look at the following example:

<script setup lang="ts">
const wendys = ref<Wendys[]>([]);
const burgerKings = ref<BurgerKing[]>([]);

onMounted(async () => {
  const wendysLocations: Wendys[] = await getAllWendys();
  const burgerKingLocations: BurgerKing[] = await getAllBurgerKings();
  wendys.value = findNearbyLocations(wendysLocations, 5);
  burgerKings.value = findNearbyLocations(burgerKingLocations, 5);
});

function findNearbyLocations(locations: (Wendys | BurgerKing)[], maxDistance: number) {
  return locations.filter((location) => location.distance <= maxDistance);
}
</script>

In this example, we're fetching all Wendy's locations and trying to filter them down to locations within 5 miles. Then, we're setting it to the wendys variable.

But wait, TypeScript is yelling at me. Why?

If we hover over the findNearbyLocations function, we'll see what the return value actually is.

function findNearbyLocations(locations: (Wendys | BurgerKing)[], maxDistance: number): (Wendys | BurgerKing)[];

Huh, okay. So the function still returns an array of Wendys OR BurgerKing, even though I KNOW the filter will only return one or the other.

This is because TypeScript isn't that smart, and sometimes doesn't understand the full context of your code.

Well, how do we fix this? One method is to say fuck TypeScript and deal with the problems another day:

function findNearbyLocations(locations: any[], maxDistance: number): any[] {
  return locations.filter((location) => location.distance <= maxDistance);
}

But this will cause any variable we use with this function to also take the any[] type. That's not good, so we need another way. Here is where Type Generics come in:

<script setup lang="ts">
onMounted(async () => {
  const wendysLocations: Wendys[] = await getAllWendys();
  const burgerKingLocations: BurgerKing[] = await getAllBurgerKings();
  wendys.value = findNearbyLocations<Wendys>(wendysLocations, 5);
  burgerKings.value = findNearbyLocations<BurgerKing>(burgerKingLocations, 5);
});

function findNearbyLocations<RestaurantType>(locations: any[], maxDistance: number): RestaurantType[] {
  return locations.filter((location) => location.distance <= maxDistance);
}
</script>

Type Generics are just like any other parameter of your function, except it references a type rather than a value. By manually specifying the type when using the function, we can tell TypeScript to expect the output to be of the same type.

But wait, we still have to manually specify the return type! What if I'm a lazy bum and don't want to do that?

Well, we can type parameters just like a type:

function findNearbyLocations<RestaurantType>(locations: RestaurantType[], maxDistance: number) {
  return locations.filter((location) => location.distance <= maxDistance);
}

By setting it to the type of the locations parameter, TypeScript will infer the RestaurantType type based on what type locations is. As a bonus, we don't even need to explicitly declare the return type anymore! This function is simple enough that TypeScript can infer it.

In the wild, Type Generic parameters are usually named T (short for Type).

Just like regular paramaters, type generic parameters can be named whatever, so don't be alarmed when you see T. In the examples above, we named T as RestaurantType.

Okay, we used this magical Type Generic. But we're still getting an error?

A Type Generic inherently has no meaning - it represents any type. It could be a string, or a number, or a function - who knows!

In our function, we are trying to access location.distance, which are properties of the Wendys and BurgerKing types. However, because our RestaurantType can be any type, TypeScript assumes that it could be a string. String.distance is not a thing, so TypeScript throws an error.

To fix this problem, we need to narrow the possible types RestaurantType could be: (RestaurantType is also renamed to T because that's the convention)

function findNearbyLocations<T extends Wendys | BurgerKing>(locations: T[], maxDistance: number) {
  return locations.filter((location) => location.distance <= maxDistance);
}

We have now told TypeScript that T can ONLY be of type Wendys or BurgerKing. Now the errors are gone, and our code works perfectly fine :)

Overloads

Sometimes Type Generics aren't enough to deal with your drunk TypeScript compiler. Take the following example:

export async function waitOneSecond<T>(condition: () => boolean, callbackFunction: () => T, defaultReturnValue?: T): Promise<T> {
  // question mark in a parameter means it's optional. if not provided, it will default to undefined
  // async functions always return a promise
  await wait(1); // wait 1 second

  return condition() ? callbackFunction() : defaultReturnValue;
}

This function takes a function that returns a boolean, which acts as a check that will run after the 1 second is up. Then, if the check returns true, then the callbackFunction will be returned; if not, the default value will be returned.

But TypeScript is saying that undefined is not applicable to T... why is that?

Because the defaultReturnValue is optional, it MAY be undefined. Even when you define it when you actually use the function, TypeScript doesn't know that! It will still assume that it may be undefined.

Okay, so how do we fix this?

// overload signature 1
export async function waitOneSecond<T>(condition: () => boolean, callbackFunction: () => T, defaultReturnValue: T): Promise<T>;

// overload signature 2
export async function waitOneSecond<T>(condition: () => boolean, callbackFunction: () => T): Promise<T | undefined>;

// actual function
export async function waitOneSecond<T>(condition: () => boolean, callbackFunction: () => T, defaultReturnValue?: T): Promise<T | undefined> {
  // question mark in a parameter means it's optional. if not provided, it will default to undefined
  // async functions always return a promise
  await wait(1); // wait 1 second

  return condition() ? callbackFunction() : defaultReturnValue;
}

The fuck? I thought you couldn't redeclare functions twice, let alone three times...

If you notice, the two upper functions don't have a {}, which means they don't return a value, which means they're not actually functions. They're called overload signatures.

By declaring every case that your function (and its parameters) may be in, TypeScript can infer the return type based on what parameters are given.

onMounted(async () => {
  userLogin();
  loaded.value = await waitOneSecond(
    () => isLoggedIn.value, // condition
    () => true, // callbackFunction
    false // defaultReturnValue
  );
});

In this case, the defaultReturnValue is specified, so TypeScript knows that the only overload signature the function can possibly be in is overload 1. This overload only returns Promise<T>, so the return value of this function must be Promise<T>.

onMounted(async () => {
  userLogin();
  loaded.value = await waitOneSecond(
    () => isLoggedIn.value, // condition
    () => true // callbackFunction
    // defaultReturnValue is not defined because we said it's an optional parameter
  );
});

In this case, the defaultReturnValue is NOT specified, so TypeScript knows that the only overload signature the function can possibly be in is overload 2. This overload returns Promise<T | undefined>, so the return value of this function must be Promise<T | undefined>.