Learning TypeScipt | Advent of TS '24
Eshaan Aggarwal @EshaanAgg
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
1type 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
1type 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
1const 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
1const 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
1const 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
1const 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
1const 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
1declare 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
1declare 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:
1enum Direction {2 Up, // 03 Down, // 14 Left, // 25 Right, // 36}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:
1enum Direction {2 Up = 10, // 103 Down, // 114 Left = 20, // 205 Right, // 216}You can also explicitly assign numbers to every member:
1enum 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:
1console.log(StatusCode.OK); // 2002console.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:
1enum 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:
1enum 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.
1type Gift = {2 name: string;3 price: number;4};5
6type 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:
1type StringRecord = Record<string, string>;2
3type Stringify<T extends StringRecord> =4 `${keyof T extends string ? keyof T : ""}: ${T[keyof T]}`;5
6type Excuse<T extends StringRecord> = new (obj: T) => Stringify<T>;-
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
Recordprovided by TypeScript and create the typeStringRecordthat only allows objects with string keys and string values. -
Next, we work on defining the
Stringifytype, 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 Twould return us a union of the object’s keys, and we can then use that to access the object’s properties asT[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 thekeyof Tcan contain types such assymbolandundefined, 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]}.
- The
-
In the last step, we define the
Excusetype, a constructor type that accepts an object of typeTand returns a string of the form${key}: ${value}. We make use of theStringifytype 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.
1type Reverse<T extends any[], Accumulator extends any[] = []> = T extends [2 infer Head,3 ...infer Rest,4]5 ? Reverse<Rest, [Head, ...Accumulator]>6 : Accumulator;7
8type Original = ["Alice", "Bob", "Charlie"];9type 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.
1type Rating = "naughty" | "nice";2type FlipRating<Cur extends Rating> = Cur extends "naughty"3 ? "nice"4 : "naughty";5
6type NaughtyOrNice<7 S extends string,8 Ty extends Rating = "naughty",9> = S extends `${infer _}${infer Rest}`10 ? NaughtyOrNice<Rest, FlipRating<Ty>>11 : Ty;12
13type ConvertToNum<S extends string> = S extends `${infer Num extends number}`14 ? Num15 : never;16
17type 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:
Ratingis a simple union type that can take the values “naughty” or “nice”.FlipRatingis a utility type that flips the value of theRatingtype. If the input is “naughty”, it returns “nice”, and vice versa.NaughtyOrNiceis a recursive utility type that takes a string and returns the rating of the string. It does so by iterating over the string and flipping the value of theTytype for each character in the string.ConvertToNumis a utility type that converts a string to a number. It does so by checking if the string can be converted to a number, and if so, returns the number. If not, it returnsnever. We cleverly use the template literal types along with type constraints on the same to achieve this.ObjectFromArrayis a utility type that takes an array of strings and returns an object with the name, count, and rating properties. It does so by extracting the elements of the array and converting them to the appropriate 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:
1type 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:
1type 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.
- Covariant types allow substituting a more specific (subtype) type for a more general (supertype) type. For instance, arrays in TypeScript are covariant:
string[]can safely be assigned toreadonly (string | number)[]becausestringis a subtype ofstring | number. - Contravariant types, however, allow substituting a more general type for a more specific one. Function parameters in TypeScript are contravariant under strict function checks: a function expecting a general parameter type can accept a function with a specific parameter type.
- Bivariant types allow both directions: more general or specific types can be substituted. This happens, for example, in event listeners or function parameters without strict function types enabled.
- Invariant types do not allow substitution in either direction. TypeScript’s generics default to invariant, meaning you cannot assign
Foo<SubType>toFoo<SuperType>or vice versa without explicit variance.
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.
1type 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:
1function* generatorFunc(): Generator<number, string, void> {2 yield 1;3 yield 2;4 return "Done"; // Return type: string5}6
7const gen = generatorFunc();8console.log(gen.next()); // { value: 1, done: false }9console.log(gen.return()); // { value: "Done", done: true }Here, Generator<Y, R, N> represents:
Y: Type of values yielded (e.g.,number).R: Return type when the generator is done (e.g.,string).N: Type of values passed tonext()(e.g.,voidsince there is no input).
An async generator works similarly but returns an asynchronous iterator using the AsyncGenerator type:
1async function* asyncGenFunc(): AsyncGenerator<number, void, string> {2 const input = yield 1; // Input type: string3 console.log(input); // Logs input passed to `next()`4 yield 2; // Output type: number5}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:
1type 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.
1type Arr<Len extends number, Acc extends any[] = []> = Acc["length"] extends Len2 ? Acc3 : Arr<Len, [...Acc, 0]>;4
5type 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:
1type 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 true9 ? 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
14type TrimLeft<15 Str extends string,16 Char extends string,17> = Str extends `${Char}${infer Tail}` ? TrimLeft<Tail, Char> : Str;18
19type TrimRight<20 Str extends string,21 Char extends string,22> = Str extends `${infer Head}${Char}` ? TrimRight<Head, Char> : Str;23
24type Trim<Str extends string, Char extends string> = TrimLeft<25 TrimRight<Str, Char>,26 Char27>;28
29type 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:
Inp: The input string that we are processing.ParsingDashes: A boolean that tells us if we are currently parsing the dashes or the actual route name. This is set totrueinitially, assuming that the first location,A, would not have any dashes.CurName: The current route name that we are parsing.CurDashArr: An array that keeps track of the number of dashes in each route name.
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.
1type 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:
1type AddThreeIntegers = Curry<[number, number, number], number>;2
3const addThree: AddThreeIntegers = (a) => (b) => (c) => a + b + c;4
5const add1 = addThree(1); // (b) => (c) => 1 + b + c6const add1And2 = add1(2); // (c) => 1 + 2 + c7const result = add1And2(3); // 1 + 2 + 3 = 68
9console.log(result); // Output: 610console.log(addThree(1)(2)(3)); // Output: 611
12// @ts-expect-error - Too few arguments13addThree(1)();14// @ts-expect-error - Incorrect argument type15addThree(1)("two");16// @ts-expect-error - Too many arguments17addThree(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.
1type Curry<Args extends unknown[], Return> = {2 <Provided extends unknown[]>(3 ...args: Provided4 ): Provided extends Args5 ? Return6 : Args extends [...Provided, ...infer Remaining]7 ? Curry<Remaining, Return>8 : never;9};10
11declare 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:
- In line 2, we define a function signature whose arguments are of the type
Provided(whereProvidedis generic with respect to the current function call). - In line 4, we check if the
Providedarguments match theArgstype. If they do, we return theReturntype as we can be sure that the complete set of arguments has been provided. - If not, in line 6, we check if the
Argstype can be split into theProvidedand theRemainingarguments. If it can, we return a newCurrytype with theRemainingarguments and theReturntype.
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!
1declare 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:
1const 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
6const upperCase = <T extends string>(x: T) => x.toUpperCase() as Uppercase<T>;7const lowerCase = <T extends string>(x: T) => x.toLowerCase() as Lowercase<T>;8const firstChar = <T extends string>(x: T) =>9 x[0] as T extends `${infer F}${infer _}` ? F : never;10const firstItem = <T extends string[]>(x: T) => x[0] as T[0];11const makeTuple = <T extends string>(x: T): [T] => [x];12const 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.
Day 18
TypeScript’s type inference is a powerful feature, but sometimes’s too powerful! You might need to make use of the NoInfer utility type to prevent TypeScript from inferring the type of a function’s argument sometimes!
Solution
Today’s problem is perhaps the most elementary use of the NoInfer type:
1const createStreetLight = <T extends string>(2 colors: T[],3 defaultColor: NoInfer<T>,4) => {5 console.log(colors);6 return defaultColor;7};We want to provide a default color for the street light, but we don’t want TypeScript to infer the type of the default color from the value of the defaultColor argument. Rather, it should try to infer it from either the colors array or the type provided explicitly and enforce that the defaultColor is the same type as the colors in the array. If we weren’t to use the NoInfer type, TypeScript would infer the type of defaultColor as the type of the value passed to it, which opens up to type errors! A simple wrap of the T type with NoInfer ensures that TypeScript does not infer the type of the default color from the value passed to it.
Day 19
Today’s challenge might seem like a stretch, but it requires only simple template matching and some help from the Trim helpers we composed in the previous day’s solutions! (Hint: Developing smaller types that parse one type of statement or remove extraneous characters might be a good approach to simplify the associated complexity!)
Solution
There are multiple ways of approaching the problem, but here is what I came up with:
1type ParseVariableDeclaration<T extends string> = T extends `${2 | "let"3 | "const"4 | "var"} ${infer Name} = "${infer Value}"`5 ? {6 id: Name;7 type: "VariableDeclaration";8 }9 : never;10
11type ParseFunctionCall<T extends string> = T extends `${infer _}(${infer Arg})`12 ? {13 type: "CallExpression";14 argument: Arg;15 }16 : never;17
18type ParseStatement<S extends string> =19 S extends `${"let" | "const" | "var"}${any}`20 ? ParseVariableDeclaration<S>21 : ParseFunctionCall<S>;22
23type TrimFront<S extends string> = S extends `${" " | "\t" | "\n"}${infer Rest}`24 ? TrimFront<Rest>25 : S;26
27type TrimBack<S extends string> = S extends `${infer Rest}${" " | "\t" | "\n"}`28 ? TrimBack<Rest>29 : S;30
31type Trim<S extends string> = TrimFront<TrimBack<S>>;32
33type Parse<S extends string> =34 Trim<S> extends `${infer Stat};${infer Rest}`35 ? [ParseStatement<Stat>, ...Parse<Rest>]36 : [];The ParseVariableDeclaration and ParseFunctionCall are the most elementary types of statements our parser supports and use simple template matching to extract the required information. I was pretty satisfied with the use of ${"let" | "const" | "var"} in the ParseVariableDeclaration type, as it helped me to condense the code and make it more readable.
The ParseStatement type is the main type that parses the provided string as a single statement. We just check if the statement begins with either of var, let or const and then parse it as a variable declaration, else we parse it as a function call. The TrimFront and TrimBack are simple utilities that remove whitespaces, tabs, and newlines from the front and the end of the provided type string, and Trim works as a wrapper around both of them.
Finally, the Parse type is the main type that parses the provided string as a series of statements. We use the ; character as a delimiter to split the string into individual statements and then parse each statement using the ParseStatement type. The result is an array of parsed statements, and basic recursion is used to complete the job!
Day 20
Can you build upon the previous day’s solution to implement a slightly more powerful parser?
Solution
1type TrimFront<S extends string> = S extends `${" " | "\t" | "\n"}${infer Rest}`2 ? TrimFront<Rest>3 : S;4
5type TrimBack<S extends string> = S extends `${infer Rest}${" " | "\t" | "\n"}`6 ? TrimBack<Rest>7 : S;8
9type Trim<S extends string> = TrimFront<TrimBack<S>>;10
11type ParseDeclaration<T extends string, Curr extends unknown[]> = T extends `${12 | "let"13 | "const"14 | "var"} ${infer Name} = "${any}"`15 ? [...Curr, Name]16 : Curr;17
18type ParseUsed<19 T extends string,20 Curr extends unknown[],21> = T extends `${infer _}(${infer Arg})` ? [...Curr, Arg] : Curr;22
23type AnalyzeScope<24 S extends string,25 Dec extends unknown[] = [],26 Used extends unknown[] = [],27> =28 Trim<S> extends `${infer Stat};${infer Rest}`29 ? AnalyzeScope<Rest, ParseDeclaration<Stat, Dec>, ParseUsed<Stat, Used>>30 : {31 declared: Dec;32 used: Used;33 };The solution is a remodification of the previous day’s solution. We first start with the basic Trim utilities to remove the leading and trailing whitespaces, tabs, and newlines from the provided string. We then modify the ParseStatement utilities to accept the current statement and an additional variable Curr (which represents a list of the currently parsed variables). This type parses the appropriate type of the statement and adds the newly parsed variable to the Curr list.
Finally the AnalyzeScope is a simple wrapper that maintains two variables Dec (for the declaration variables) and Used (for the used variables). It uses the ParseDeclaration and ParseUsed types to parse the provided string and extract the declared and used variables. The result is an object with the declared and used variables, and the function uses basic recursion to parse the entire string.
Day 21
Think about how you might use recursion and some utility types to perform a difference operation on two lists!
Solution
A brute force way of implementing the difference operation between two lists might look as:
1type In<Element, List extends unknown[]> = List extends [2 infer First,3 ...infer Rest,4]5 ? First extends Element6 ? true7 : In<Element, Rest>8 : false;9
10type Diff<A extends unknown[], B extends unknown[]> = A extends [11 infer First,12 ...infer Rest,13]14 ? In<First, B> extends true15 ? Diff<Rest, B>16 : [First, ...Diff<Rest, B>]17 : [];Here, the In type checks if an element is present in a list by recursively infering the first element of the list and checking if it matches the provided element. The Diff type then uses the In type to check if the first element of the first list is present in the second list. If it is, it skips the element, else it adds it to the result list. The process is repeated recursively until the first list is exhausted. While this implementation is good enough for small lists, it might not be the most efficient for large lists due to the recursive nature of the solution. We can instead use a more efficient solution that replaces the recursive calls for the In type with a more efficient lookup operation using some TypeScipt magic:
1type Difference<2 A extends readonly unknown[],3 B extends readonly unknown[],4 Curr extends unknown[] = [],5> = A extends [infer Head, ...infer Rest]6 ? Head extends B[number]7 ? Difference<Rest, B, Curr>8 : Difference<Rest, B, [...Curr, Head]>9 : Curr;We make use of the neat fact that we can use Arr[number] to get a union of all the elements of any array Arr in TypeScript. Thus, we can use B[number] to get a union of all the elements of the second list B. We then check if the Head element of the first list is present in the second list, and if it is, we skip it, else we add it to the Curr list. This way, we can avoid the recursive calls for the In type and make the solution more efficient.
Once we have this utility type in place, we just need to modify the structure of the final output type from yesterday’s solution to use the Difference type:
1type TrimFront<S extends string> = S extends `${" " | "\t" | "\n"}${infer Rest}`2 ? TrimFront<Rest>3 : S;4
5type TrimBack<S extends string> = S extends `${infer Rest}${" " | "\t" | "\n"}`6 ? TrimBack<Rest>7 : S;8
9type Trim<S extends string> = TrimFront<TrimBack<S>>;10
11type ParseDeclaration<T extends string, Curr extends unknown[]> = T extends `${12 | "let"13 | "const"14 | "var"} ${infer Name} = "${any}"`15 ? [...Curr, Name]16 : Curr;17
18type ParseUsed<19 T extends string,20 Curr extends unknown[],21> = T extends `${infer _}(${infer Arg})` ? [...Curr, Arg] : Curr;22
23type Difference<24 A extends readonly unknown[],25 B extends readonly unknown[],26 Curr extends unknown[] = [],27> = A extends [infer Head, ...infer Rest]28 ? Head extends B[number]29 ? Difference<Rest, B, Curr>30 : Difference<Rest, B, [...Curr, Head]>31 : Curr;32
33type Lint<34 S extends string,35 Dec extends unknown[] = [],36 Used extends unknown[] = [],37> =38 Trim<S> extends `${infer Stat};${infer Rest}`39 ? Lint<Rest, ParseDeclaration<Stat, Dec>, ParseUsed<Stat, Used>>40 : {41 scope: {42 declared: Dec;43 used: Used;44 };45 unused: Difference<Dec, Used>;46 };