Skip to main content

Learning TypeScipt | Advent of TS '24

Image of the author

Eshaan Aggarwal @EshaanAgg

Christmas background with caption 'Learn TS with Advent of TypeScript'

I love the concept of Advent Calendars, and I am glad there is one for TypeScript, too! 🎄

I first learned about TypeScript in 2022, and since then, TypeScript has become the first dependency I add to any Node or JavaScript project I work on. The developer experience and the tooling associated with the TS ecosystem are too good to pass on, especially as your project scales in size.

The Advent of TypeScript is a yearly event in December, during which a new TypeScript concept is introduced each day. I decided to participate in the same this year, and I will document what I have learned here.

You probably should have “some” idea of TypeScript to follow along, but even if you don’t, that’s fine. The whole point of events like Advent of TypeScript is to learn something new, and I would encourage you to participate! The blog has no rigid structure and would alternate between briefly discussing the concepts involved in solving the day’s problem and sharing resources to read about them and the solution itself! All the solutions would be wrapped in a collapsible, so you can try solving the problem yourself before looking at the solution!

Let me know if the blog was helpful to you and if you would like me to continue with it in the future!

PS. Try solving the problems yourself before looking for solutions. It is a great way to learn that will not only teach TypeScript but also teach you to read and debug TypeScript code, making you a better problem solver and developer! 🚀


Day 1

To define a type in typescript, you use the type keyword followed by the name of the type and its definition. There are many primitive types associated with TypeScript, such as number, string, boolean, null, undefined, symbol, and object. You can also define custom types using the type keyword.

Solution

1
type Demand = number;

Day 2

The types are not limited to “type”s; you can use numbers, strings, and constants (declared with const) as types. This is useful when you want to limit a variable’s values.

Solution

1
type Demand = 900000;

Day 3

TypeScript can be used to annotate functions as well. You can define the types of arguments that a function takes and the type of the function’s return value. This is a major advantage of TypeScript over JavaScript, as it allows you to catch type errors at compile time and helps ensure that all the expected values are passed to the function.

Solution

1
const survivalRatio = (input: number) => {
2
const data = annualData[input];
3
if (!data) {
4
throw new Error("Data not found");
5
}
6
return data.housingIndex / data.minimumWage;
7
};

Day 4

You can use the concept of type unions to define a type that can take multiple values. Think of the same as an OR operation, where the type can be either of the types defined in the union.

The typeof operator can be used to determine the type of a variable at runtime. This is useful when performing different operations based on the variable type.

When using type unions, you can use conditionals to narrow the type associated with the provided variable. TypeScript is smart enough to understand that if a variable is checked for a particular type, it must be of that type in the subsequent code blocks.

Solution

1
const survivalRatio = (input: number | string) => {
2
const quarter = typeof input === "string" ? input : `${input} Q1`;
3
const data = quarterlyData[quarter];
4
if (!data) {
5
throw new Error("Data not found");
6
}
7
return data.housingIndex / data.minimumWage;
8
};

Day 5

Generics are a powerful feature of TypeScript that allows you to define a type that can take multiple types as arguments. This is useful when writing a function or a class that can work with different data types.

Generics are defined using the <> syntax, followed by the type parameter’s name. You can then use this type parameter as a placeholder for the actual type that will be passed to the function or class. This allows you to write more flexible and reusable code, as you can define the type of data that the function or class will work with when you call it.

Generics can be thought of as variables for types. They allow you to write functions, classes, and interfaces that can work with any data without specifying the type explicitly until you consume the function or class. They can be a bit tiresome to understand at first, but once you get the hang of them, they can be a powerful tool in your TypeScript arsenal.

Solution

1
const createRoute = <T>(author: string, route: T): T => {
2
console.log(`[createRoute] route created by ${author} at ${Date.now()}`);
3
return route;
4
};

Day 6

The extends keyword can be used to define constraints on the type parameter of a generic function or class. This allows you to restrict the types that can be passed to the function or class and helps to ensure that the function or class works correctly with the data passed to it.

Solution

1
const createRoute = <Route extends number | string>(
2
author: string,
3
route: Route,
4
) => {
5
console.log(`[createRoute] route created by ${author} at ${Date.now()}`);
6
return route;
7
};

Day 7

The const keyword can define a constant value in TypeScript. This is useful when defining a value that cannot be changed once set. Constants often define values used throughout your code, such as configuration settings or default values.

const is particularly useful for narrowing the variable type, as TypeScript can infer the type of a constant based on the value assigned to it.

Solution

1
const createRoute = <const Route extends string[]>(
2
author: string,
3
route: Route,
4
) => ({
5
author,
6
route,
7
createdAt: Date.now(),
8
});

Day 8

NodeJS defines global variables available in all modules. These variables can be used to access information about the current module, such as the filename, directory name, and module exports, and do not need to be imported.

You can use the concept of TypeScipt modules and namespaces to organize your code, prevent naming conflicts, and make your code more readable and maintainable. These can also be used to extend the type definitions of third-party libraries and even built-in NodeJS modules.

Today will also be a good day to brush up on the concept of interfaces in TypeScript, which are used to define the shape of an object. Though interfaces and types are similar, interfaces are more commonly used to define the structure of an object. They are “extendable” in nature, making them a better choice when you want to define an object’s structure that other users can extend in the future.

Solution

1
declare namespace NodeJS {
2
interface ProcessEnv {
3
MOOD_LIGHTS: "true";
4
BATH_TEMPERATURE: "327.59";
5
STRAWBERRIES: "chocolate";
6
}
7
}

Day 9

Modules can also define types for NPM packages and external libraries. Using the export keyword allows you to export a type from a module, making it available to others that import it. This is particularly useful if you want to define some internal types in a module that you do not want your module’s other modules and consumers to have access to.

Solution

1
declare module "santas-special-list" {
2
export type Status = "naughty" | "nice";
3
export type Child = {
4
name: string;
5
status: Status;
6
};
7
export type List = Child[];
8
}

Day 10

Enums in TypeScript are a way to define a set of named constants, which can represent numeric or string values. By default, TypeScript enums are associated with numbers and auto-increment their values starting from 0. For example:

1
enum Direction {
2
Up, // 0
3
Down, // 1
4
Left, // 2
5
Right, // 3
6
}

The numbers assigned to the enum members can be explicitly defined, allowing you to customize the values or skip certain numbers. Once a value is assigned to an enum member, the subsequent members continue auto-incrementing from that value:

1
enum Direction {
2
Up = 10, // 10
3
Down, // 11
4
Left = 20, // 20
5
Right, // 21
6
}

You can also explicitly assign numbers to every member:

1
enum StatusCode {
2
OK = 200,
3
BadRequest = 400,
4
NotFound = 404,
5
}

You can access both the name and the number, as enums in TypeScript support reverse mapping:

1
console.log(StatusCode.OK); // 200
2
console.log(StatusCode[200]); // "OK"

Enums can be changed or extended by assigning new numbers or values during their declaration. This flexibility makes them a powerful feature in TypeScript for managing sets of related constants.

Solution

Well, the first thought is to create a simple enum with the required mappings, something like:

1
enum Gift {
2
Coal,
3
Train,
4
Bicycle,
5
Traditional,
6
SuccessorToTheNintendoSwitch,
7
TikTokPremium = 8,
8
Vape = 16,
9
OnTheMove = 26,
10
OnTheCouch = 28,
11
}

The same would satisfy all the constraints, but upon submitting, you get an error message about using invalid characters like 6, 7, 9, etc. Looking at the note in the problem statement, we find the large note about NOT trying to copy the enum values from the problem statement. After looking at the huge hint, we do realize that we can make use of binary operators to solve the problem:

1
enum Gift {
2
Coal,
3
Train,
4
Bicycle,
5
Traditional,
6
SuccessorToTheNintendoSwitch,
7
TikTokPremium = SuccessorToTheNintendoSwitch << 1,
8
Vape = TikTokPremium << 1,
9
OnTheMove = Vape | TikTokPremium | Bicycle,
10
OnTheCouch = Coal | TikTokPremium | Vape | SuccessorToTheNintendoSwitch,
11
}

Day 11

The new keyword is used in JavaScript to call the constructors of any object in the object. You can also extend this concept to TypeScript, use the new keyword to create instances of classes and provide the required type definitions to the class’s constructor.

1
type Gift = {
2
name: string;
3
price: number;
4
};
5
6
type GiftConstructor = new (name: string, price: number) => Gift;

You might also need to learn about conditional types to solve the problem. Conditional types are a powerful feature of TypeScript that allows you to define types based on a condition. This is useful when you want to create a type that depends on another type’s value and helps ensure the type system is flexible and can adapt to different situations.

Looping over the keys of an object can be done using the keyof operator, which returns a union of the object’s keys. You can then access and perform operations on the object’s properties using this union. It would also help to know about template literals to solve the problem.

PS. This is the beginning of the actual “hard” part of the Advent of TypeScript, and the problems scale very quickly from this onwards. The solutions use many advanced TypeScript features and would probably not strike you if you see them for the first time. Please do not get demotivated, and you can try solving the other TypeHero challenges to get a better hang of such problem-solving patterns.

Solution

First, let us read through the provided tests and try to reason about what is happening. After a couple of glances, we realize that, indeed, the type Excuse is a constructor type in which we can pass any object with a key value, and then the final returned object from the constructor is a string of the form ${key}: ${value}. We can break the solution into multiple parts and solve it piecewise:

1
type StringRecord = Record<string, string>;
2
3
type Stringify<T extends StringRecord> =
4
`${keyof T extends string ? keyof T : ""}: ${T[keyof T]}`;
5
6
type Excuse<T extends StringRecord> = new (obj: T) => Stringify<T>;
  1. First, we need to define a type guard that only allows the objects with both strings as keys and values. For this, we use the utility type Record provided by TypeScript and create the type StringRecord that only allows objects with string keys and string values.

  2. Next, we work on defining the Stringify type, which accepts a “string” object and then converts it to the appropriate ${key}: ${value} format. We make use of the template literal types to achieve the same.

    • The keyof T would return us a union of the object’s keys, and we can then use that to access the object’s properties as T[keyof T].
    • The union CAN contain multiple keys (as we are not restricting the same), but we can be sure that the tests only pass objects with a single key-value pair, so we can safely assume that the union would only contain a single key, and we can access the keys and values as above.
    • Thus, the template literal should be of the form ${keyof T}: ${T[keyof T]}, but because the keyof T can contain types such as symbol and undefined, we need to ensure that we only consider the string keys. Thus, we use the conditional operator to check if the key is a string, and then we use the key and value to form the template literal.
    • Thus, the final type would be the form ${keyof T extends string ? keyof T : ""}: ${T[keyof T]}.
  3. In the last step, we define the Excuse type, a constructor type that accepts an object of type T and returns a string of the form ${key}: ${value}. We make use of the Stringify type to ensure that the object passed to the constructor is of the correct type.


Day 12

Using recursion in types is a powerful feature of TypeScript that allows you to define types that depend on themselves.

Let us try to solve a simple problem. Suppose we have a type representing a list of strings like ["Alice", "Bob", "Charlie"], and you want to create a type that reverses the order of the list so that it becomes ["Charlie", "Bob", "Alice"]. You can use recursion to define a type that takes the first element of the list, appends it to the end of the reversed list, and then calls itself with the rest. For this, it is common to use variables like Accumulator and Rest to keep track of the reversed list and the remaining list, respectively, and to use the infer keyword to infer the type of the first element of the list. TypeScript also supports destructuring of array types using the ... operator and the concept of default type arguments.

1
type Reverse<T extends any[], Accumulator extends any[] = []> = T extends [
2
infer Head,
3
...infer Rest,
4
]
5
? Reverse<Rest, [Head, ...Accumulator]>
6
: Accumulator;
7
8
type Original = ["Alice", "Bob", "Charlie"];
9
type Reversed = Reverse<Original>; // ["Charlie", "Bob", "Alice"]

This is a common recursive pattern! I would highly encourage you to first test out this recursive pattern on your own for today’s problem (without seeing the hint) and then try thinking if the same can be improved upon to work with the humongous input provided in this day’s tests!

Solution

Today’s solution is a bit longer and has multiple moving parts. I have tried to break them into smaller parts and combine them to form the final solution.

1
type Rating = "naughty" | "nice";
2
type FlipRating<Cur extends Rating> = Cur extends "naughty"
3
? "nice"
4
: "naughty";
5
6
type NaughtyOrNice<
7
S extends string,
8
Ty extends Rating = "naughty",
9
> = S extends `${infer _}${infer Rest}`
10
? NaughtyOrNice<Rest, FlipRating<Ty>>
11
: Ty;
12
13
type ConvertToNum<S extends string> = S extends `${infer Num extends number}`
14
? Num
15
: never;
16
17
type ObjectFromArray<Arr extends [string, string, string]> = {
18
name: Arr[0];
19
count: ConvertToNum<Arr[2]>;
20
rating: NaughtyOrNice<Arr[0]>;
21
};

In the above utility types:

Now, with all these utility types in place, we can define the final type to iterate over the provided array and convert it to the required array of objects:

1
type FormatNames<
2
Arr extends [string, string, string][],
3
Acc extends any[] = [],
4
> = Arr extends [
5
infer Curr extends [string, string, string],
6
...infer Rest extends [string, string, string][],
7
]
8
? FormatNames<Rest, [...Acc, ObjectFromArray<Curr>]>
9
: Acc;

But, no surprise, the same does not work due to the large nature of the provided input, and we get the error of infinite possible nesting. To solve the same, we need to figure out a non-recursive way of looping over the array, and this is where indexed types come into play (yes, they can be used to work with arrays as well!). We can make use of the keyof operator to iterate over the keys of the array (with would be the indexes associated with the elements), and then access the actual values as Arr[K] where K is the index (key). The actual solution would look something like:

1
type FormatNames<Arr extends [string, string, string][]> = {
2
[K in keyof Arr]: ObjectFromArray<Arr[K]>;
3
};

This was indeed a lengthy day, but after this day, you should feel comfortable with all the common techniques and tricks that are used in what is effectively “advanced” TypeScript problem-solving.

PS. All the code I have shown you today can be compressed and typed in way fewer keystrokes (by inlining the types and removing some type constraints with extends), but I have tried to keep the code as verbose as possible to make it easier to understand. You can try to compress the code and see how much you can reduce the number of lines and characters in the code!


Day 13

In TypeScript, variance describes how types relate to each other, particularly in generic types and function parameters.

To ensure type safety, these distinctions matter when working with generic constraints, function types, and array-like structures. You can read about the same in detail here and see how their implementations can be realized practically. The variance annotations section from the official documentation might also be a good reference for solving today’s problem.

Solution

The generic of Demand needs to be invariant to any types passed to it (it doesn’t accept any more specific or vague types). We can use the out keyword so that only anything more specific can be passed to T, and the in keyword so that only anything more general can be passed to T.

1
type Demand<in out T> = {
2
demand: T;
3
};

PS. This solution is exactly what the official documentation says you SHOULD NOT do! I couldn’t figure out a better solution, and the provided tests were not very helpful in this case. If you have a better solution, do let me know!


Day 14

In TypeScript, generators, and async generators have specialized type annotations to describe their input, output, and return types.

A generator function returns an iterator and uses the Generator type:

1
function* generatorFunc(): Generator<number, string, void> {
2
yield 1;
3
yield 2;
4
return "Done"; // Return type: string
5
}
6
7
const gen = generatorFunc();
8
console.log(gen.next()); // { value: 1, done: false }
9
console.log(gen.return()); // { value: "Done", done: true }

Here, Generator<Y, R, N> represents:

An async generator works similarly but returns an asynchronous iterator using the AsyncGenerator type:

1
async function* asyncGenFunc(): AsyncGenerator<number, void, string> {
2
const input = yield 1; // Input type: string
3
console.log(input); // Logs input passed to `next()`
4
yield 2; // Output type: number
5
}
6
7
(async () => {
8
const asyncGen = asyncGenFunc();
9
console.log(await asyncGen.next()); // { value: 1, done: false }
10
console.log(await asyncGen.next("Hello")); // Logs "Hello", then { value: 2, done: false }
11
})();

Equipped with this knowledge, you can now try to solve today’s problem!

Solution

Today’s challenge is easier than the previous ones, and it can be solved by studying the provided tests. In the tests, we use the ReturnType utility type to extract the return type of the provided generator function, which is equivalent to the R type in the Generator type.

Thus a simple infer and extends can be used to extract the return type of the generator function:

1
type PerfReview<T> =
2
T extends AsyncGenerator<infer R, infer _, infer _> ? R : never;

Day 15

TypeScript provides no method to work with numbers and basic arithmetic. However, we can use the length of arrays/tuples to perform basic arithmetic operations. For example, to add two numbers, you can create an array of the length of the first number and then concatenate it with an array of the length of the second number. The length of the resulting array will be the sum of the two numbers.

1
type Arr<Len extends number, Acc extends any[] = []> = Acc["length"] extends Len
2
? Acc
3
: Arr<Len, [...Acc, 0]>;
4
5
type Add<A extends number, B extends number> = [...Arr<A>, ...Arr<B>]["length"];

Here the type Arr is a utility type that creates an array of the specified length, and the type Add is a utility type that adds two numbers by creating arrays of the specified lengths and concatenating them.

You can also use this technique to perform other arithmetic operations and get creative.

Solution

The solution is a bit more complex than the previous ones but is easy to implement once you get the hang of the same:

1
type Process<
2
Inp extends string,
3
ParsingDashes extends boolean = true,
4
CurName extends string = "",
5
CurDashArr extends any[] = [],
6
> = Inp extends `${infer First}${infer Rest}`
7
? First extends "-"
8
? ParsingDashes extends true
9
? Process<Rest, true, CurName, [...CurDashArr, 1]>
10
: [[CurName, CurDashArr["length"]], ...Process<Inp, true>]
11
: Process<Rest, false, `${CurName}${First}`, CurDashArr>
12
: [[CurName, CurDashArr["length"]]];
13
14
type TrimLeft<
15
Str extends string,
16
Char extends string,
17
> = Str extends `${Char}${infer Tail}` ? TrimLeft<Tail, Char> : Str;
18
19
type TrimRight<
20
Str extends string,
21
Char extends string,
22
> = Str extends `${infer Head}${Char}` ? TrimRight<Head, Char> : Str;
23
24
type Trim<Str extends string, Char extends string> = TrimLeft<
25
TrimRight<Str, Char>,
26
Char
27
>;
28
29
type GetRoute<S extends string> =
30
Trim<S, "-"> extends "" ? [] : Process<Trim<S, "-">>;

The main crux of the solution is in the Process type, which takes the input string and processes it to extract the route and the number of dashes in the route. As each string is supposed to be of the type A--B--C--D, we wish to divide it into segments of the form A, --B, --C, --D. We do this by maintaining the following state variables:

The type then iterates over the input string, dividing it into two parts: First representing the first character of the string and Rest representing the rest. If the first character is a dash, and we are currently parsing the dashes, we add the dash to the CurDashArr, otherwise it means that we have reached the end of a route segment (of the form ---B) and thus we add the current route name and the number of dashes to the result array and start parsing the next route name. If the first character is not a dash, we add it to the CurName and continue parsing the route name.

The TrimLeft, TrimRight, and Trim types are utility types that trim the input string from the left, right, and both sides, respectively. This removes any leading or trailing dashes from the input string. These are necessary to handle edge cases like ---A-- or ---- that might be present in some of the test cases. Finally, the GetRoute type is a utility type that takes the input string and first trims it to remove the troublesome leading and trailing dashes and then processes it to extract the route and the number of dashes in each route name.


Day 16

Currying is a technique in functional programming where a function with multiple arguments is transformed into a sequence of functions, each taking a single argument. This allows you to partially apply the function by passing some of the arguments and then apply the remaining arguments later. This way, you can create new functions by combining existing functions and build more complex functions from simpler ones.

In TypeScript, you can use generic and conditional types to create a curried function that takes multiple arguments and returns a sequence of functions. You can then use these functions to build more complex functions by combining them with other functions.

1
type Curry<T extends any[], R> = T extends [infer F, ...infer Rest]
2
? (arg: F) => Curry<Rest, R>
3
: R;

Here, the Curry type is a utility type that takes an array of arguments T and a return type R, and returns a function sequence that takes the arguments individually and returns the final result. The type uses conditional types to recursively build the sequence of functions and the infer keyword to infer the types of the arguments and the return type. This type applies one argument at a time and returns a new function that takes the next argument until all the arguments have been applied and the final result is returned.

A simple implementation of a curried function that can add three numbers may be done as follows:

1
type AddThreeIntegers = Curry<[number, number, number], number>;
2
3
const addThree: AddThreeIntegers = (a) => (b) => (c) => a + b + c;
4
5
const add1 = addThree(1); // (b) => (c) => 1 + b + c
6
const add1And2 = add1(2); // (c) => 1 + 2 + c
7
const result = add1And2(3); // 1 + 2 + 3 = 6
8
9
console.log(result); // Output: 6
10
console.log(addThree(1)(2)(3)); // Output: 6
11
12
// @ts-expect-error - Too few arguments
13
addThree(1)();
14
// @ts-expect-error - Incorrect argument type
15
addThree(1)("two");
16
// @ts-expect-error - Too many arguments
17
addThree(1)(2, 3);

Can you use this technique to solve today’s problem? (Hint: The number of arguments you apply in each step is not fixed and can vary based on the input. Thus, you might need to define a union of all possible invocations!).

Solution

This is one of the days where seeing the solution first and then trying to reason about it might be a better approach.

1
type Curry<Args extends unknown[], Return> = {
2
<Provided extends unknown[]>(
3
...args: Provided
4
): Provided extends Args
5
? Return
6
: Args extends [...Provided, ...infer Remaining]
7
? Curry<Remaining, Return>
8
: never;
9
};
10
11
declare function DynamicParamsCurrying<Args extends unknown[], Ret>(
12
fn: (...args: Args) => Ret,
13
): Curry<Args, Ret>;

As you can see here, the definition of the Curry type has been modified to be a type union:

This clever use of conditional types, recursion, and generics to define a currying function can enumerate all possible invocations and return the final result when all the arguments have been provided.

Finally, we provide the DynamicParamsCurrying function that takes a function and returns a curried version of the function. This function uses the Curry type to define the curried version of the function and can be used to curry any function with any number of arguments.

You can also use the Parameters and ReturnType utility types to extract the parameters and return type of the function!

1
declare function DynamicParamsCurrying<Fn extends (...args: any[]) => any>(
2
fn: Fn,
3
): Curry<Parameters<Fn>, ReturnType<Fn>>;

Day 17

TypeScript provides several utility types that can be used to manipulate and transform types. These utility types can create new types from existing types and help simplify complex type definitions in common workflows!

Read about them, and even try implementing them yourself to better understand the power of the TypeScript ecosystem!

Solution

This day was a bit trickier than usual, as getting the types correct for the utility functions was an underestimated task. The final solution would look something like:

1
const compose =
2
<A, B, C, D>(f: (x: A) => B, g: (x: B) => C, h: (x: C) => D) =>
3
(a: A): D =>
4
h(g(f(a)));
5
6
const upperCase = <T extends string>(x: T) => x.toUpperCase() as Uppercase<T>;
7
const lowerCase = <T extends string>(x: T) => x.toLowerCase() as Lowercase<T>;
8
const firstChar = <T extends string>(x: T) =>
9
x[0] as T extends `${infer F}${infer _}` ? F : never;
10
const firstItem = <T extends string[]>(x: T) => x[0] as T[0];
11
const makeTuple = <T extends string>(x: T): [T] => [x];
12
const makeBox = <T>(value: T): { value: T } => ({ value });

For the compose function, we make use of 4 type parameters, where A is the input type, B is the type of the first function’s output, C is the type of the second function’s output, and D is the final output type. Defining these variables allowed us to correctly constrain the signatures of the intermediate functions f, g, and h. We could also have used the TypeScipt utility type ReturnType to extract the return type and Parameters to extract the parameters of the functions, but the same was not required in this case.

To define the types of the utility functions, we make use of utility types such as Lowercase and Uppercase, along with typecasting with the as keyword to ensure that the return types are of the correct type. We also use template literal types to extract a string’s first character and an array’s first item.