Hidden Aspects of TypeScript and How to Resolve Them
Byteminds Agency
Posted on May 21, 2024
We suggest using a special editor to immediately check each example while reading the article. This editor is convenient because you can switch the TypeScript version in it.
Getting “any” instead of “unknown”
When we use the “any” type, we lose typing - we can access any method or property of such an object, and the compiler will not warn us about possible errors. If we use “unknown”, the compiler will notify us of potential issues.
Some functions and operations return “any” by default - this is not entirely obvious, here are some examples:
// JSON.parse
const a = JSON.parse('{ a: 1 }'); // any
// Array.isArray
function parse(a: unknown) {
if (Array.isArray(a)) {
console.log(a); // a[any]
}
}
// fetch
fetch("/")
.then((res) => res.json())
.then((json) => {
console.log(json); // any
});
// localStorage, sessionStorage
const b = localStorage.a; // any
const c = sessionStorage.b // any
ts-reset can solve this problem.
ts-reset is a library that helps solve some non-obvious issues where we wish TypeScript worked differently by default.
Array methods are too strict for the “as const” construct
This issue is also found in the “has” methods of “Set” and “Map”.
Example: we create an array of users, assign the “as const” construct, then call the “includes” method and get an error because argument 4 does not exist in the “userIds” type.
const userIds = [1, 2, 3] as const;
userIds.includes(4);
ts-reset will also help get rid of this error.
Filtering an array from “undefined”
Let's say we have a numeric array that may contain “undefined”. To get rid of these “undefined”, we filter the array. But the “newArr” array will still contain the array type “number” or “undefined”.
const arr = [1, 2, undefined];
const newArr = arr.filter((item) => item !== undefined);
We can solve the problem like this, and then “newArr2” will have the type “number”:
const newArr2 = arr.filter((item): item is number => item !== undefined);
Also, ts-reset can help but only for the case when the “filter” function argument is “BooleanConstructor” type.
const filteredArray = [1, 2, undefined].filter(Boolean)
Narrowing a type using bracket notation
We create an object with a type key string, value string, or array of strings.
We then access the object's property using bracket notation and check that the object's return type is a string. In TypeScript versions below 4.7, the “queryCountry” type will be a string or an array of strings, i.e. automatic type narrowing does not work, even though we have already checked the condition.
However, if you use TypeScript version 4.7 and above, type narrowing will work as expected.
const query: Record<string, string | string[]> = {};
const COUNTRY_KEY = 'country';
if (typeof query[COUNTRY_KEY] === 'string') {
const queryCountry: string = query[COUNTRY_KEY];
}
Enum problems
We create an “enum” and do not specify the values explicitly, so each key in order will have numerical values from 0 onwards.
Using this “enum”, we type the first argument of the “showMessage” function, expecting that we will be able to pass only those codes that are described in the “enum”:
enum LogLevel {
Debug, // 0
Log, // 1
Warning, // 2
Error // 3
}
const showMessage = (logLevel: LogLevel, message: string) => {
// code...
}
showMessage(0, 'debug message');
showMessage(2, 'warning message');
If we pass a value not contained in the “enum” as an argument, we should see the error "Argument of type '-100' is not assignable to parameter of type 'LogLevel'." But in TypeScript versions below 5.0, this error doesn’t occur, although logically it should:
showMessage(-100, 'any message')
We can also create an “enum” and explicitly specify numeric values. We indicate the “enum” type to the constant “a” and assign any non-existent number that is not in the “enum”, for example, 1. When using TypeScript versions below 5, there will be no error.
enum SomeEvenDigit {
Zero = 0,
Two = 2,
Four = 4
}
const a: SomeEvenDigit = 1;
And one more thing: when using TypeScript below version 5, calculated values cannot be used in “enum”.
enum User {
name = 'name',
userName = `user${User.name}`
}
Functions that have an explicit return type of “undefined” must have an explicit return
In versions of TypeScript below 5.1, an error will appear in cases where a function has an explicit type of “undefined”, but no “return”.
function f4(): undefined {}
There will be no error in the following cases:
function f1() {}
function f2(): void {}
function f3(): any {}
To summarize, if we explicitly assign the type “void” or “any” to a function, there will be no error. It will appear if we assign a function type “undefined”, and only when using TypeScript version below 5.1.
The behavior of “enums” follows nominative typing, not structural typing
This is, even though TypeScript uses structural typing.
Let's create an “enum” and a function whose argument we type with this “enum”. Then we try to call the function passing a string that is identical to one of the enum values as the argument. We get an error in “showMessage”: the argument type “Debug” cannot be assigned because the “enum” type “LogLevel” is expected.
enum LogLevel {
Debug = 'Debug',
Error = 'Error'
}
const showMessage = (logLevel: LogLevel, message: string) => {
// code...
}
showMessage('Debug', 'some text')
Even if we create a new “enum” with the same values, it won't work.
enum LogLevel2 {
Debug = 'Debug',
Error = 'Error'
}
showMessage(LogLevel2.Debug, 'some text')
The solution is to use objects with the value “as const”.
const LOG_LEVEL = {
DEBUG: 'debug',
ERROR: 'error'
} as const
type ObjectValues = T[keyof T]
type LogLevel = ObjectValues;
const logMessage = (logLevel: LogLevel, message: string) => {
// code...
}
In this case, we can pass anything, and there will be no error because we are working with a simple value, and it does not matter where it is passed from.
logMessage('debug', 'some text')
logMessage(LOG_LEVEL.DEBUG, 'some text')
Possibility of returning the wrong data type in function with overloading
Suppose we want to return a string from a function if 2 of its arguments are strings. We create such functions and then check whether our arguments are strings. In this case, we can return any data type, even though a string was specified in the first step.
function add(x: string, y: string): string
function add(x: number, y: number): number
function add(x: unknown, y: unknown): unknown {
if (typeof x === 'string' && typeof y === 'string') {
return 100;
}
if (typeof x === 'number' && typeof y === 'number') {
return x + y
}
throw new Error('invalid arguments passed');
}
Next, we expect that “const” will contain the type “string”, but we get a number.
const str = add("Hello", "World!");
const num = add(10, 20);
Passing an object as an argument to a function with an extra property
When typing the arguments of functions and classes, we cannot add extra properties that were not originally specified in the type or interface. After all, in this case, we are simply passing a different structure as an argument.
However, in TypeScript, it is possible to break this rule:
type Func = () => {
id: string;
};
const func: Func = () => {
return {
id: "123",
name: "Hello!",
};
};
For greater clarity, let's create an object with the “formatAmountParams” settings, which we will pass to the “formatAmount” function. As you can see, an object with settings can contain extra properties and there will be no error.
type FormatAmount = {
currencySymbol?: string,
value: number
}
const formatAmount = ({ currencySymbol = '$', value }: FormatAmount) => {
return `${currencySymbol} ${value}`;
}
const formatAmountParams = {
currencySymbol: 'USD',
value: 10,
anotherValue: 20
}
Also, there is no error if we pass an object that contains extra properties:
formatAmount(formatAmountParams);
But we will get an error if we create an object as a function argument and pass it with an extra property.
formatAmount({ currencySymbol: '', value: 10, anotherValue: 12 });
In addition, we may face unexpected behavior if we want to rename “currencySymbol” to “currencySign”.
First, let's change the type, then TypeScript will prompt that we need to change the key in the object from “currencySymbol” to “currencySign”.
type FormatAmount = {
currencySign?: string,
value: number
}
const formatAmount = ({ currencySign = '$', value }: FormatAmount) => {
return `${currencySign} ${value}`;
}
const formatAmountParams = {
currencySymbol: 'USD',
value: 10
}
formatAmount(formatAmountParams);
There are no errors - so we might think that the refactoring went smoothly. But in “formatAmountParams” the old name “currencySymbol” remains, and instead of the expected result “USD 10” we will get “$10”.
Loss of typing when using “Object.keys”
Let's create an “obj” object. “Using Object.keys”, let's create an array with the object's keys and iterate through this array. If we access an object by key in a loop, TypeScript will say that we cannot do this because the generic type “string” cannot be used as a key for the “obj” object.
A possible solution is to cast the type using the “as” construct. But this can be unsafe because we are manually setting what type will be there. We need to ensure that [key] is not just a string, but a key, and indicate this explicitly.
const obj = {a: 1, b: 2}
Object.keys(obj).forEach((key) => {
console.log(obj[key])
console.log(key as keyof typeof obj)
});
TypeScript may not recognize data type changes
Let's create a “UserMetadata” type as a key-value “Map”. Based on this type, we create a “cache” and try to get the value for the key “foo” using the “get” method. Everything works as expected.
Next, we'll create a “cacheCopy” object based on “cache” and also call the “get” method. TypeScript won't indicate that anything is wrong, but there will be an error because the object doesn't have a “get” method.
type Metadata = {};
type UserMetadata = Map<string, Metadata>;
const cache: UserMetadata = new Map();
console.log(cache.get('foo'));
const cacheCopy: UserMetadata = { ...cache };
console.log(cacheCopy.get('foo'));
Merge interfaces
Interfaces, unlike types, can merge. If there are interfaces with the same names in one file, then when we assign this interface, it will contain properties from all interfaces with the same names.
interface User {
id: number;
}
interface User {
name: string;
}
// Error: Property 'id' is missing in type '{ name: string; }' but required in type 'User', because User interfaces merged
const user: User = {
name: 'bar',
}
Moreover, if we have global interfaces, for example, predefined in TypeScript itself, they will also be merged. For example, if we create an interface named “comment”, we will get a merge of interfaces because “comment” already exists in “lib.dom.d.ts”.
interface Comment {
id: number;
text: string;
}
// Error: Type '{ id: number; text: string; }' is missing the following properties from type 'Comment': data, length, ownerDocument, appendData, and 59 more.
const comment: Comment = {
id: 5,
text: "good video!",
};
If you want to review the topic but don’t want to read the article again, you can watch a few videos on YouTube:
Be Careful With Return Types In TypeScript
Enums considered harmful
Author: Andrey Stepanov
Posted on May 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.