Type Challenge 011
Tuple Type to Object? Difficulty: Easy (Not That Easy)
Type Challenge Problem #11 appears early when you start solving Type Challenges, and it's categorized as "easy." However, similar to problem #4, if you're just starting with TypeScript, this problem might feel overwhelming. In my opinion, it involves intermediate or higher-level TypeScript concepts, and you need to be familiar with various theories to solve it.
To solve this problem, you need knowledge of tuples, immutability (readonly, PropertyKey), mapped types (union, symbol), computed property keys, structural typing, and nominal typing.
This post analyzes the problem, reviews the necessary theoretical concepts, and then derives a solution.
Problem Analysis
"A tuple type is another sort of Array type that knows exactly how many elements it contains, and exactly which types it contains at specific positions."
- TypeScript Document
If you transition from JavaScript to TypeScript, this definition might not be intuitive. Breaking it down into three key points with examples makes it easier to understand:
1) A tuple is an array : [ ]
2) A tuple has a fixed length: [ , , ]
3) Each element in a tuple has a fixed type: [string, number, string]
(Obviously, only values of the specified types can be assigned to each element.)
“Simply put, a tuple is an array with a fixed length and type.”
Tuples are straightforward when they contain primitive types, but they become more complex when they include object types or generics. However, with a bit of thought, they aren't too difficult to grasp.
There are two primary reasons to use tuples:
1. When returning multiple values from a function and their order matters:
const calc = (a: number, b: string): [number, string] => {
return [a + 1, b + " Hello"]
}
const [newNum, newHello] = calc(1, "hb")
console.log(newNum) // 2
console.log(newHello) // hb Hello
2. When a simple object is needed but without key-value pairs for performance reasons:
type User ={
name: string,
age: number
}
const objUser:User ={
name:'hb',
age:12
}
const tupleUser: [string, number] = ['hb', 12]
2. Immutable
Immutability means that a value cannot be changed after it has been created. This concept exists in JavaScript and is also present in TypeScript.
Immutability is commonly applied to tuples and object types, though it can also be applied to primitive types. There are multiple ways to enforce immutability, depending on the target, purpose, and TypeScript version. The most common methods are Literal, readonly, Readonly, and as const :
// 1️⃣ Literal - Primarily used for primitive types
const hello: "Hello world" = "Hello world";
// The `hello` variable can only hold the value "Hello world" and cannot be changed.
// 2️⃣ readonly - Used for object properties
type Read = {
readonly read1: string;
read2: number;
};
// 3️⃣ Readonly - A utility type that makes all object properties readonly
interface Read2 {
read1: string;
read2: number;
}
const read2: Readonly<Read2> = {
read1: "hb",
read2: 12,
};
// 4️⃣ as const - Used for array and tuple immutability
const ConstType = ["Hello", "world", "hb", 12] as const;
Focusing more on as const.
Literals were introduced in TypeScript 2.1 and appeared relatively early. They are primarily used to ensure immutability for primitive values (i.e., to fix them in place).
readonly was introduced in TypeScript 2.0, also in the early days of TypeScript. It was introduced to explicitly apply immutability to object types such as objects and arrays.
as const was introduced in TypeScript 3.4.
If literals ensure immutability for primitive types and readonly ensures immutability for object types, why do we need as const?
Before answering that, there's an important theory to understand. In TypeScript, keys are restricted to the string, number, and symbol types. Going deeper, only types that support structural typing can be used as keys, while types that support nominal typing cannot. However, this creates a contradiction—TypeScript allows symbol as a key type, but symbol follows nominal typing.
Key Takeaways:
In TypeScript, keys can only be string, number, or symbol.
Only structurally-typed types can be used as keys, while nominally-typed types cannot.
Symbol ollows nominal typing.
This seems contradictory—only structural typing is allowed, yet symbol is permitted, even though it is not structurally typed? Keep this in mind.
Let's analyze the fourth example in the provided code:
const ConstType = ['Hello', 'world', 'hb', 12] as const;
This is equivalent to:
const ConstType: ['Hello', 'world', 'hb', 12] = ['Hello', 'world', 'hb', 12];
So why do we use as const?
As mentioned earlier, TypeScript only allows string, number, and symbol as keys. string and number follow structural typing:
const a = 1;
const b = 2;
console.log(a === b); // true
Just like numbers, strings are considered equal if their values match.
However, symbol, which follows nominal typing, behaves differently:
const sym1 = Symbol(1);
const sym2 = Symbol(1);
console.log(sym1 === sym2); // false
Even though both symbols were created with the same value, they are distinct because they reference different memory locations in the heap.
Thus, even if you attempt to use Symbol()
as a literal key, the values will be different, making access impossible.
For example:
const ConstType2: [Symbol(1), 12] = [Symbol(1), 12]; // ❌ Error
The Symbol(1) declared as a literal key is different from the Symbol(1) value created at runtime, so the declaration fails.
To resolve this, you can use as const to ensure that the same symbol is used consistently as both the key and the value:
const ConstType2 = [Symbol(1), 12] as const;
This ensures that the Symbol(1) declared here retains the same key/value identity throughout its use.
3. keyof any, Property Key
As mentioned earlier, TypeScript only allows string, number, and symbol as keys.
any → Represents all types.
keyof → A keyword that returns the keys of a type as a union.
When applied to all types, keyof returns a union of keys. Since the only valid key types in TypeScript are string, number, and symbol, keyof any evaluates to string | number | symbol.
A key distinction to note is that (keyof any)[] and keyof any[] are different.
keyof any is string | number | symbol, so (keyof any)[] evaluates to string[] | number[] | symbol[].
keyof any[], on the other hand, applies keyof to any[], which results in string | number | symbol.
PropertyKey is simply a utility type for (typeof any). As a result, PropertyKey[] evaluates to string[] | number[] | symbol[].
4. mapped type
Mapped types were covered in Type Challenge #004, so instead of diving into the theory, I'll focus on simple usage guidelines.
A mapped type is a syntax for iterating over a type. Just like foreach, map, let of, and let in are used to iterate over arrays and objects, mapped types provide a way to iterate over types. It’s not a particularly difficult concept.
When using mapped types, there are a few
[P in K]:P
Conditions for Mapped Type Syntax
K must be a union type used as a range.
The square brackets []
represent a loop.
K = 'hello' | 'world' | 'hb' is a union type composed of string literals. When used as a range, K allows P to access each individual element within it.
Here’s your translated text with bold formatting:
There are two main ways and keywords to return a union type:
keyof: A keyword that returns the keys of an object type as a literal union type.
K[number]: A syntax that returns the elements of an array or tuple type as a union type.
5. Computed Property Key
Here’s your translated text with bold formatting:
Computed Property Key is a syntax that allows converting a value directly into a key by using [variable name].
For example:
const name = "max"
type HB = {
[name]:"HB"
}
Here’s your translated text with bold formatting:
When given this kind of code, name is "max"
. The type HB contains a key named [name]. Therefore, HB is as follows.
const name = "max"
type HB = {
max:"HB"
}
Here’s your translated text with bold formatting:
This syntax is particularly useful when using Symbol as a key. In fact, primitive types do not cause significant issues even when redeclared, as long as their values remain the same.
For example, looking at the two codes above, they are identical in every aspect and function correctly. The reason for this is that name is of type string.
However, the situation changes when Symbol is used.
const sym1 = Symbol(1)
type Sym = {
Symbol(1): typeof sym1
}
Declaring Symbol(1) as a key in this manner will not only throw a compilation error but also make it impossible to access Symbol(1) of type Sym.
In such cases, the Computed Property Type using square brackets [ ] becomes particularly useful.
const sym1 = Symbol(1)
type Sym = {
[sym1]: typeof sym1
}
If used in this way, it becomes possible to access the sym1 key of type Sym, ensuring that the code functions as intended.
Now that we've covered the necessary theoretical concepts, let's analyze and solve the problem.
// Question
type TupleToObject<T extends readonly any[]> = any;
//tc
const tuple = ["tesla", "model 3", "model X", "model Y"] as const;
const tupleNumber = [1, 2, 3, 4] as const;
const sym1 = Symbol(1);
const sym2 = Symbol(2);
const tupleSymbol = [sym1, sym2] as const;
const tupleMix = [1, "2", 3, "4", sym1] as const;
type cases = [
Expect<
Equal<
TupleToObject<typeof tuple>,
{
"tesla": "tesla";
"model 3": "model 3";
"model X": "model X";
"model Y": "model Y";
}
>
>,
Expect<Equal<TupleToObject<typeof tupleNumber>, { 1: 1; 2: 2; 3: 3; 4: 4 }>>,
Expect<
Equal<
TupleToObject<typeof tupleSymbol>,
{ [sym1]: typeof sym1; [sym2]: typeof sym2 }
>
>,
Expect<
Equal<
TupleToObject<typeof tupleMix>,
{ 1: 1; "2": "2"; 3: 3; "4": "4"; [sym1]: typeof sym1 }
>
>
];
// @ts-expect-error
type error = TupleToObject<[[1, 2], {}]>;
The generic T in TupleToObject is a subtype of a readonly array.
tc1 is a string literal tuple type.
tc2 is a number literal tuple type.
tc3 is a symbol literal tuple type.
tc4 is a tuple type that includes string, number, and symbol literals.
tc5 throws an error when an object is used as a key.
TupleToObject iterates through each element of the tuple type, making each element both a key and a value of the TupleToObject type.
T is a subtype of a readonly array, and the array must be a tuple containing string, number, or symbol literals. An error occurs if an object is used as a key.
TupleToObject iterates through each element of the tuple type, making each element both a key and a value of the TupleToObject type.
By considering all these conditions, the problem can be solved as follows.
This problem, which involves tuples, may seem more difficult than its actual complexity suggests. It also requires an understanding of Symbol, a type that is not frequently used.
Since Symbol is rarely encountered in everyday TypeScript development, this problem provided a great opportunity to explore and understand its theoretical background and syntax. While the problem may initially appear challenging, it can be solved by carefully reading and understanding the underlying concepts step by step.