Type Challenge 004

CNHur HyeonBin (Max)
Reponses  01month ago( Both Korean and English Version )

Problem Analysis

 

Type Challeng 004 Question.png

 

To summarize, the problem requires implementing Pick, a built-in utility type in TypeScript, from scratch. The goal is to define MyPick to function like Pick, ensuring it passes the three given test cases.

To solve this problem, you need to understand five key concepts. (This post covers only the essential theories needed to solve the problem.)

 

  • What is a Generic?

  • What is Pick?

  • What is a Mapped Type?

  • What is keyof?

  • What is extends?

 

Then, let's go over each concept one by one.

 


 

What is a Generic in TypeScript?

In TypeScript, generics are one of the most frequently used but also one of the first major hurdles to overcome. The reason for this is that TypeScript operates slightly differently from the traditional coding mechanisms we are used to.

 

Typically, when writing code, we work with variables and values to perform computations. However, in TypeScript, we deal with variables, values, and types. To fully leverage TypeScript’s advantages, we must first understand its type system and fundamental mechanisms.

 

Generics are one of the most commonly used and important elements in TypeScript’s type system. In simple terms, generics allow us to use types as variables.

 

When we write code, we use variables, and the reasons for using variables include:

  1. Storing values for reuse.

  2. Using operations to reduce code duplication and increase flexibility.

 

We instinctively develop software using variables based on these principles. Let's look at a simple example.

 

If we write a simple program to calculate taxes, we might structure it like this:

console.log("Product price: " + 1000 + "$");
console.log("Tax: " + 1000*0.1 + "$");
console.log("Total Price: " + 1000+(1000*0.1 ) + "$");

 

But what if the price isn’t always 10,00? What if the tax rate isn’t always 0.1?

To make our code more flexible and reusable, we aim for an approach like the one below.

 

// Declare variables for product price and tax rate
let price = 1000;
let taxRate = 0.1;

// Calculate Tax
let tax = price * taxRate;

// Calculate the final price
let totalPrice = price + tax;

// Use variables multiple times for output
console.log("Product Price: " + price + "$");
console.log("Tax: " + tax + "$");
console.log("Total Price: " + totalPrice + "$");

 

Generics work just like variables. They allow us to use types as variables, reducing code duplication and increasing flexibility.

Let's write a program that takes an array as an argument and prints the first element.

 
 
 

 

function getFirstElement(arr: number[]){
  console.log(arr[0]) 
}

console.log(getFirstElement([1, 2, 3])) // 1 (T → number)

 

But what if the array is not of type number[] but string[] or boolean[]?

In that case, we would have to write three separate functions with different types or use function overloading, which would make the code longer and less maintainable.

 

function getFirstElement(arr: number[]){
  console.log(arr[0]) 
}
function getFirstElement_str(arr: string[]) {
  console.log(arr[0])
}
function getFirstElement_bool(arr: boolean[]) {
  console.log(arr[0])
}

 

By using generics to treat the type as a variable, we can write simpler and more flexible code.

 

function getFirstElement<T>(arr: T[]){
  console.log(arr[0]) 
}

getFirstElement<number>([1, 2, 3]) // 1 (T → number)
getFirstElement<string>(["a", "b", "c"]) // "a" (T → string)
getFirstElement<boolean>([true, false]) // true (T → boolean)

 

Let's analyze the code.

 

Generic Example.png

 

Generic example 2.png

 

It simply uses the type as a variable and adjusts it according to the situation. This is very similar to how we use regular variables. Here are a few examples of generics.

 

type Test <T> = {
    test : T
}

const t1: Test<number> = { test: 1 }
const t2: Test<string> = { test: "hello" }
const t3: Test<boolean> = { test: true }

-----

type Test2<T,U> = {
  first: T,
  second: U
}

const test1 :Test2<number, string> = {
    first: 1,
    second: "Hello"
}

 

In one sentence, generics can be understood as taking types as arguments and using them like variables, just as functions take variables as arguments and operate on them.

 


 

What is Pick?

 

Pick is a built-in utility type in TypeScript. Utility types are used to transform existing types into new ones, providing pre-implemented transformations to make modifying existing types easier.

 

While the theoretical explanation may seem complex, the actual usage of Pick is quite straightforward.

 

// Built-in Type
type Alread = {
    first: string,
    second:number,
    third:boolean
    forth: string,
    fifth: number
}

// Custom Type
type NewType = {
    first: string,
    third: boolean,
}

 

NewType was implemented as a completely new type. The important point here is that NewType must be a subtype of the Alread type.

Now, let's assume that if the newly created type is not a subtype of the existing type, the code will throw an error.

 

type Alread = {
    first: string,
    second:number,
    third:boolean
    forth: string,
    fifth: number
}

type NewType1 = {
    first: number, // <- is different as Alread.first
    third: boolean,
}

type NewType2 = {
    first: string,
    third: boolean,
   sixth: number, // <- Alread does not includes sixth
}

 

Both NewType1 and NewType2 will throw an error.

 

When multiple developers work together and modify types, such mistakes can happen quite easily. To prevent this, Pick is used to create a strict subset type.

 

type Alread = {
    first: string,
    second:number,
    third:boolean
    forth: string,
    fifth: number
}

type NewType =  Pick<Alread, "first"|"third"> 

 

The code has become much more concise, improving readability.

Looking at the actual type of NewType, we can see that it has been created exactly as expected.

 

NewType Image.png

 


 

What is a Mapped Type?

 

A mapped type is a syntax used to iterate over multiple types.

It works similarly to how we use map or forEach to iterate over arrays.

// Basic Syntax of Mapped Types
[P in T] : T[P]

 

Just as map and forEach are used to iterate over arrays, mapped types are used to iterate over union types.

 

mapped type example.png

 

Since it iterates over the given union type to define a new type, it can be expanded and explained as follows.

 

type Status = "pending" | "approved" | "rejected"

type StatusMessages = {
  [K in Status]: string
}

/*
type StatusMessages = {
  pending: string;
  approved: string;
  rejected: string;
}
*/

// 결과 타입:
// {
//   pending: string;
//   approved: string;
//   rejected: string;
// }

 

What is extends?

extends is a keyword in TypeScript used for type extension. It is mainly used to create subtypes or to check if a type is a subtype of another.

Before diving into extends, let's briefly understand subtypes and supertypes in a general sense, without coding.

 

Thinking in General Terms

Let's consider a rectangle and a square. Which one is the supertype, and which one is the subtype?

It's easy to see that a rectangle is the supertype, while a square is the subtype.

Now, imagine red fruit and strawberries. Which one is the supertype, and which one is the subtype?

 

Similarly, red fruit is the supertype, and strawberries are the subtype.

 

Logical Explanation

If we analyze why we think this way, the reasoning is quite simple:

  • Every square can be considered a rectangle

  • Every rectangle can be considered a square

  • Every strawberry can be considered a red fruit

  • Every red fruit can be considered a strawberry

 

We usually think this way, considering squares and strawberries as subtypes, while rectangles and red fruits are supertypes.

In TypeScript, this concept is referred to as upcasting and downcasting.

  • Upcasting: Using a subtype as a supertype.

  • Downcasting: Using a supertype as a subtype.

If upcasting is possible, we can establish a parent-child relationship between the two types, referring to them as supertype and subtype. This principle is formally known as the Liskov Substitution Principle (LSP).

 

A subtype is an extension of a supertype, meaning it is a more specific type. Meanwhile, a supertype encompasses a broader range of types, including its subtypes.

From a property perspective, a subtype contains all the properties of a supertype and may also include additional properties. However, visualizing this concept using a Venn diagram may be misleading, so be cautious when interpreting it.

 

1. extends for createing Subtypes

interface Rectangle {
   hasFourAngle: boolean
   hasFourSide: boolean
}

interface Square extends Reactangle{
   isAll90Degree: boolean
}

const rect: Rectangle = {
  hasFourAngle: true,
  hasFourSide: true
}

const squ: Square = {
 hasFourAngle: true,
 hasFourSide: true,
 isAll90Degree: true
}

 

2. extends for checking Subtypes

 

interface Rectangle {
  hasFourAngle: boolean;
  hasFourSide: boolean;
}

interface Square extends Rectangle {
  isAll90Degree: boolean;
}

// The Utility type to check subtype relationship
type IsSubtype<T, U> = T extends U ? true : false;

// is Square subtype of Rectangle?
type Test = IsSubtype<Square, Rectangle>; // true

// is Rectangle subtype of Square?
type Test2 = IsSubtype<Rectangle, Square>; // false

 

What is keyof?

In JavaScript or TypeScript, we often use the typeof keyword to check the type of a value. While the typeof operator returns the type of a value, the keyof operator is used to retrieve the keys of an object as a union type.

Difference Between typeof and keyof

  • typeof → Returns the type of a value.

  • keyof → Returns the keys of an object as a union type.

 


 

If You Understand Generics, Pick, Mapped Types, Extends, and Keyof, Let's Analyze and Solve the Problem.

 

Problem Analysis

 

type challenge question.png

 

All unnecessary linting in Cases must be removed.

Let's examine each test case:

  • MyPick<Todo, 'title'> should be equal to Expected1.

  • MyPick<Todo, 'title' | 'completed'> should be equal to Expected2.

  • MyPick<Todo, 'title' | 'completed' | 'invalid'> should throw a type error.

 

Solution

1. Mapped Type

When substituting actual types into the generics for the three test cases, we can see that:

  • Each property follows the mapped type format of the generic K.

  • The type of each property corresponds to the property type of T.

 

type solution1.png

 

1-1. MyPick<T, K> Properties

When defining MyPick<T, K>, the properties are a mapped type of K, written as [P in K].

1-2. Property Types

The type of each property is derived from T's property types, specifically T[P].

Thus, we can define MyPick<T, K> as:
{ [P in K]: T[P] }

 

  1. Understanding extends keyof

If you don’t fully grasp the core principle of extends, this can be one of the most difficult concepts to understand. Personally, I spent the most time trying to comprehend this.

extends is a keyword used for type extension, either to create a subtype or to check whether a type is a subtype. Given this, let's consider why extends is necessary in this context.

Looking at Test Case 1 and 2, we see that the generic K acts as a mapped type and defines the property keys of MyPick. The type of each key corresponds to T[property].

However, in Test Case 3, if a key like 'invalid'—which does not exist in T—is passed, then T['invalid'] does not exist, leading to an error.

 

  1. The Subtype Relationship and Logical Conflict

Essentially, K is a subtype of T, and in general, subtypes tend to have more properties than their supertypes. However, this assumption leads to a logical conflict.

Logic 1: Subtypes Have More Properties

A subtype typically contains more properties than its supertype, often including properties that do not exist in the supertype. This is because subtypes provide more detailed and specific type information.

If K is a subtype of T, it might have more properties than T. If we denote a property of K as P, then T[P] may not exist.

Logic 2: MyPick's Properties Must Exist in T

Since the properties of MyPick are derived from T, what happens if T[P] does not exist? Why do we need extends, and how does it help?

Although K is a subtype of T and may contain additional properties that T lacks, T[P] throws an error when P belongs to K but not to T.

 

  1. Resolving the Logical Conflict

If P is assumed to exist in K, but T[P] still causes an error, then an exceptional case must be occurring. This exception arises due to union types and string literal types.

As mentioned earlier, subtypes generally have more properties than their supertypes, making them more detailed. This reasoning also applies to union types, but an additional logical factor comes into play.

interface A {
    isA:boolean
}
interface B {
    isB: boolean
}
interface C {
    isC :boolean
}

type ABC = A|B|C 

 

Let's assume there are four different types.

 

If we visualize these four types using a Venn diagram, it would look like this.

 

ABC Type Diagram.png

 

However, rather than visualizing a union type this way, it might be easier to understand it as:

ABC = A or ABC = B or ABC = C.

 

 

ABC type new diagram.png

 

 

Therefore, while it might seem like ABC could be a subtype of A, B, or C, that is actually incorrect.

If ABC were a regular object type containing A, B, and C as properties, then it could be considered a subtype of those types. However, since it's a union type, ABC is not just a combination of A, B, and C—instead, it represents one of them at any given time.

Let's break it down:

ABC = A or B or C
Which means:
ABC = A, ABC = B, ABC = C

Now, ask the question:
If ABC = A, is A a subtype of B? Absolutely not.

A union type only becomes a subtype of all its constituent types if it satisfies all their conditions. This is why thinking in terms of properties alone can be misleading.

A better way to approach it is to consider each case separately:

ABC = A

ABC = B

ABC = C

Then, evaluate whether all conditions hold in every scenario. This step-by-step approach makes understanding union types much easier.

 

Returning to the test case, we can see that the K value is not a general union type but a string literal union type.

 

Since a string literal type can only be a subtype of itself and nothing else, if K contains a key that does not exist in T, it results in an error.

 

Summarizing the analysis so far:

  1. The property keys of MyPick follow the mapped type format of K, and their types are derived from T[P].

  2. K must be a subtype of T.

  3. When defining MyPick<T, K>, K should be a string literal union type.

From our first analysis, we established that:

MyPick<T, K> = { [P in K]: T[P] }.

 

Through the second analysis, we determined that:

MyPick<T, K extends keyof T> = { [P in K]: T[P] }

 

Applying this to Test Case 3:

MyPick<Todo, 'title' | 'completed' | 'invalid'>

  • 'title' is a subtype of Todo

  • 'completed' is a subtype of Todo

  • 'invalid' is not a subtype of Todo

Since 'invalid' does not meet the subtype condition, Test Case 3 results in an error.

 

Third Analysis: Understanding keyof

 

The third part of the analysis shows that K is a string literal union type. In TypeScript, when extracting string literal union types from an object type, the keyword used is keyof.

 

By applying this directly to our problem, we arrive at:

MyPick<T, K extends keyof T>

 

If we strip away the generic notation and the keyof keyword, the test cases are structured as follows:

 

Test Case 1:
MyPick<Todo, 'title' extends 'title' | 'description' | 'completed'>

Test Case 2:
MyPick<Todo, 'title' | 'completed' extends 'title' | 'description' | 'completed'>

Test Case 3:
MyPick<Todo, 'title' | 'completed' | 'invalid' extends 'title' | 'description' | 'completed'>

 

Since 'invalid' does not exist in Todo, the third case fails, explaining why an error occurs.

 

Answer

 

Type challenge 004 answer.png

 

type MyPick<T, K extends keyof T> = {
  [P in K] : T[P]
}

 

 

Conclusion

Type Challenge 004 is the first problem in the Type Challenges series and is categorized as easy.

However, despite its easy difficulty rating, it can feel overwhelming if you attempt it with only a basic understanding of TypeScript. The problem introduces intermediate to advanced TypeScript concepts, which can be challenging for beginners.

For this reason, I have explained the syntax and theory in great detail to ensure that even those new to TypeScript can grasp the logic behind it.

I hope this explanation helps many others, and personally, this analysis has been a valuable learning experience for me as well.

 

Thank you!

CNHur HyeonBin (Max)
Reponses  0