Quirky things about TypeScript
JavaScript is known for being a m̶a̶s̶s̶a̶c̶r̶e̶, in-famous language. This is mostly because you can write the same things in different ways, which often leads to a confusing and unsupported codebase.
Even though TypeScript restricts us from making stupid mistakes, it also brings lots of its own flaws and quirky things. Today we will review some of my favorite TS’ strangeness.
Let’s start.
?: VS && VS || VS ??
Warming up with something simple.
TypeScript provides several conditional operators ?:
, ??
, &&
, and ||
. Here's a breakdown of each:
? :
(Ternary Operator): evaluates the condition and returns one of two expressions:
true ? "true" : "false"; // "true"
false ? "true" : "false"; // "false"
1 ? "true" : "false"; // "true"
0 ? "true" : "false"; // "false"
"hello" ? "true" : "false"; // "true"
"" ? "true" : "false"; // "false"
null ? "true" : "false"; // "false"
undefined ? "true" : "false"; // "false"
[] ? "true" : "false"; // "true"
{} ? "true" : "false"; // "true"
Acts as a shorthand for an if-else
statement.
&&
(Logical AND Operator): returns the first falsy operand, or if none, the last truthy operand:
// often used in conditional rendering
isAuthenticated && "Profile Page"; // "Profile Page"
true && "True"; // "True"
false && "True"; // false
0 && "True"; // 0
1 && "True"; // "True"
null && "True"; // null
undefined && "True"; // undefined
"" && "True"; // ""
"hello" && "True"; // "hello"
// chaining
// expression && (expression && expression)
null && undefined && "True" // null
"value" && undefined && "True" // undefined
"value" && "value" && "True" // "True"
It is commonly used for conditional rendering.
||
(Logical OR Operator): returns the first truthy operand, or if none, the last falsy operand:
null || "Default"; // "Default"
undefined || "Default"; // "Default"
false || "Default"; // "Default"
true || "Default"; // true
0 || "Default"; // "Default"
1 || "Default"; // 1
"" || "Default"; // "Default"
"hello" || "Default"; // "hello"
// chaining
// expression || (expression || expression)
null || undefined || "Default" // "Default"
"value" || undefined || "Default" // "value"
null || "value" || "Default" // "value"
It’s used to provide fallback values for any falsy value (like null
, undefined
, 0
, false
, ""
).
??
(Nullish Coalescing Operator): returns the right-hand expression only when the left-hand operand isnull
orundefined
:
// only two cases when "Nullish" returned
null ?? "Nullish"; // "Nullish"
undefined ?? "Nullish"; // "Nullish"
true ?? "Nullish"; // true
false ?? "Nullish"; // false
0 ?? "Nullish"; // 0
1 ?? "Nullish"; // 1
"" ?? "Nullish"; // ""
"hello" ?? "Nullish"; // "hello"
// chaining
// expression ?? (expression ?? expression)
null ?? undefined ?? "Nullish" // "Nullish"
"value" ?? undefined ?? "Nullish" // "value"
null ?? "value" ?? "Nullish" // "value"
It’s used to provide default values for potentially null
or undefined
variables, without falsely treating other values like 0
, false
, or ""
as nullish.
Type conversion
As with other languages, TS provides multiple ways to convert one type to another.
For example, those are options to convert into a number
:
const num1 = Number("123");
const num2 = parseInt("123px"); // 123
const num3 = parseInt("123.45"); // 123
const num4 = parseFloat("123.45") // 123.45
const num5 = +"123"; // 123
const num6 = +true; // 1
const num7 = +false; // 0
const num8 = +[]; // 0
const num9 = +[123]; // 123
You can convert to a string
using:
const str1 = String(123); // "123"
const str2 = 123.toString(); // "123"
const str3 = `${123}`; // "123"
const str4 = 1 + ''; // "1"
const str5 = true + ''; // "true"
const str6 = [1, 2, 3] + ''; // '1,2,3'
And those are available options for boolean
:
const bool1 = Boolean(123); // true
const bool2 = Boolean(0); // false
const bool3 = !!"hello world"; // true
const bool4 = !!""; // false
const bool5 = !!0; // false
const bool6 = !![]; // true
const bool7 = !!{}; // true
any VS unknown
In TypeScript there are two ways to avoid type safety:
any
: disables type checking entirely, allowing any type of value to be assigned or used without restrictions
const value: any;
value = "Hello";
value = true;
// no type checking - any operations are allowed
console.log(value.toUpperCase()); // Error during execution
unknown
: represents an unknown type that requires type checking or type assertions before being used
const value: unknown;
value = "Hello";
value = true;
// type checking is enforced - need to check the type before operations
if (typeof value === 'string') {
console.log(value.toUpperCase());
}
In summary, any
allows anything without checks, while unknown
enforces type checks before usage.
null VS undefined
Another strange thing about TS is that it has two types to identify the absence of the value:
null
: indicate an empty value (value exists, but it isnull
)
let a = 4;
a = null;
undefined
: represents uninitialized variables or missing value (value does not exist)
let a; // undefined
You can also get undefined
when accessing a missing object’s property:
const obj = {
value: 27,
};
obj.Value; // undefined
In summary, null
is used intentionally to indicate no value, while undefined
is used when a value is missing or hasn't been set.
void VS never
For a function return, we also have some funny types:
void
: represents the absence of a return value. It is typically used for functions that do not return anything:
function logMessage(): void {
console.log("This function returns nothing");
}
never
: represents a function that never successfully completes (e.g., throws an error or enters an infinite loop). It indicates that the function never returns:
function throwError(): never {
throw new Error("This function never returns");
}
enum VS union VS iterable union VS object
Something as simple as a set of constants in TS can be defined in various ways:
- traditional enums
enum SortOrder {
ASC = 'ASC',
DESC = 'DESC',
}
// usage
const order1: SortOrder = SortOrder.ASC;
const order2: SortOrder = 'ASC'; // Error
const order3 = SortOrder.ASC; // type inferred: SortOrder
— traditional enum approach
— TS infers the data type when not implicitly defined
— gives a “namespace” for the constants
— type-safe
— listing enum in foreach
causes double iteration, since it is reverse-mapped
— value can be either a number
or a string
and not complex objects like an array
- union of literal types
type SortOrder = 'ASC' | 'DESC';
// usage
const order1: SortOrder = 'ASC';
const order2: SortOrder = SortOrderEnum.ASC; // can assign enum
— type is not inferred
— can be a combination of different types (number
, string
, object
, etc)
— disappears after compilation, meaning a smaller bundle size
— cannot be iterated with foreach
- iterable union types
const sortOrders = ['ASC', 'DESC'] as const;
type SortOrder = typeof sortOrders[number]; // 'ASC' | 'DESC'
// usage
const order: SortOrder= 'ASC';
for (const order of sortOrders) {
// do something
}
— same as above, but does not disappear after compilation
— iterable
- object
const SortOrderObject = {
ASC: 'ASC',
DESC: 'DESC',
} as const;
type SortOrder = typeof SortOrderObject[keyof typeof SortOrderObject];
// usage
const order1: SortOrder = 'ASC';
const order2 = SortOrderObject.ASC;
const order3 = SortOrderObject.ASC; // type: union ('ASC')
— type is not inferred
— can be used as an enum and as a constant
— smaller in size than enums after compilation
— iterate once
— values can be complex types, like array
, object
, etc
Regardless of how many options TS provides, personally, I use only traditional enums for enums 🙃.
as VS satisfies
For type assertions, you have the following keywords: as
and satisfies
.
Let’s say the next interface exists:
interface UserDto {
name: string;
age: number;
}
as
: checks for type overlaps but bypasses type-checking
// all fields are present
const user1 = {
name: 'John',
age: 18,
}
const res1 = user1 as UserDto; // OK
// an additional field is present
const user2 = {
name: 'John',
surname: 'Doe',
age: 18,
}
const res2 = user2 as UserDto; // OK
// missing a field
const user3 = {
name: 'John',
}
const res3 = user3 as UserDto; // OK
// missing a field + an additional field is present
const user4 = {
name: 'John',
surname: 'Doe',
}
const res4 = user4 as UserDto; // Error, 'age' is missing, types have no overlap
// all fields are missing
const user5 = {
surname: 'Doe',
}
const res5 = user5 as UserDto; // Error, 'name' and 'age' are missing, types have no overlap
satisfies
: enforces the type structure. If any required properties are missing or the structure does not match, TypeScript will produce an error
// all fields are present
const user1 = {
name: 'John',
age: 18,
}
const res1 = user1 satisfies UserDto; // OK
// an additional field is present
const user2 = {
name: 'John',
surname: 'Doe',
age: 18,
}
const res2 = user2 satisfies UserDto; // OK
// missing a field
const user3 = {
name: 'John',
}
const res3 = user3 satisfies UserDto; // Error, 'age' is missing
// missing a field + an additional field is present
const user4 = {
name: 'John',
surname: 'Doe',
}
const res4 = user4 satisfies UserDto; // Error, 'age' is missing
// all fields are missing
const user5 = {
surname: 'Doe',
}
const res5 = user5 satisfies UserDto; // Error, 'name' and 'age' are missing
As you can see the main difference is in the third use case. To sum up:
- Use
as
when you want to assert a type and don't mind if some properties are missing or if the structure is not strictly enforced. - Use
satisfies
when you want to ensure that a value conforms to a specific type and catch any errors related to missing or incorrect properties at compile time.
explicit type VS as VS satisfies
When declaring a variable, TS allows to specify type explicitly or use assertion operators. Let’s see it in detail.
We have the same interface.
interface UserDto {
name: string;
age: number;
}
The simplest use case is when all properties are present. TS will define the variable without any issues. In this case, let’s just focus on a variable type:
const user1: UserDto = { // type: UserDto
name: 'John',
age: 19,
};
const user2 = { // type: UserDto
name: 'John',
age: 19,
} as UserDto;
const user3 = { // type: { name: string, age: number }
name: 'John',
age: 19,
} satisfies UserDto;
const user4 = { // type: { name: string, age: number }
name: 'John',
age: 19,
}
In case some fields are missing, only as
operator won’t show any errors:
const user1: UserDto = { // Error: 'age' is missing
name: 'John',
};
const user2 = { // OK
name: 'John',
} as UserDto;
const user3 = { // Error: 'age' is missing
name: 'John'
} satisfies UserDto;
In case all fields are present, but we have an additional one, both explicit type and satisfies
will fail:
const user1: UserDto = { // Error: 'surname' does not exist in UserDto
name: 'John',
surname: 'Doe',
age: 19,
};
const user2 = { // OK
name: 'John',
surname: 'Doe',
age: 19,
} as UserDto;
const user3 = { // Error: 'surname' does not exist in UserDto
name: 'John',
surname: 'Doe',
age: 19,
} satisfies UserDto;
There are also cases, when as
operator will show errors:
// missing a field + an additional field is present
const user1 = { // Error: conversion mistake, types does not overlap
name: 'John',
surname: 'Doe',
} as UserDto;
// all fields are missing
const user2 = { // Error: conversion mistake, types does not overlap
surname: 'Doe',
} as UserDto;
Readonly<T> VS Object.freeze() VS as const
You can enforce immutability with Readonly<T>
, Object.freeze
and as const
:
Readonly<T>
: a TypeScript utility that makes all properties of a given type read-only at compile-time
const obj = {
a: {
b: 2,
},
};
const readonlyObj: Readonly<{ a: { b: number } }> = obj;
obj.a = 4; // OK
readonlyObj.a = 4; // compilation Error
readonlyObj.a.b = 3; // nested objects still can be changed
obj.a.b // 3, original object changed
— is used at compile-time
— prohibit property modification
— only makes the top-level properties read-only
Object.freeze()
: is a JavaScript method used to freeze an object
const obj = {
a: {
b: 2,
}
};
const freezedObj = Object.freeze(obj); // type: Readonly<{ a: { b: 2 } }>
// Object.freeze(obj); - also works
obj.a = 4; // execution Error, because of JS
freezedObj.a = 4; // compilation Error, because of TS
freezedObj.a.b = 3; // nested objects still can be changed
obj.a.b // , original object changed
— is used at runtime
— marks object as immutable
— only performs a shallow freeze, meaning that nested objects can still be modified
as const
: is a TypeScript feature that allows you to create a deeply immutable type
const obj = { // type { readonly a: { readonly b: 2 } }
a: {
b: 2,
}
} as const;
res.a = 4; // compilation Error
res.a.b = 3; // compilation Error
— is used at compile time
— creates an immutable type
— creates a deeply immutable type. All nested objects are treated as read-only, preventing any modification at the type level
Be aware, those can be applied at a nested level only:
const obj1 = { a: { b: 2 } as const } ;
const obj2 = { a: Object.freeze({ b: 2 }) };
typeof VS instanceof VS is VS in VS keyof
In TypeScript there are different ways of type-checking (typeof
, instanceof
, is
, and in
).
Here's a breakdown of how they differ. Let’s say we have the next code:
class Point2D {
x: number = 0;
y: number = 0;
}
class Point3D extends Point2D {
z: number = 0;
getZ = () => this.z;
}
const point = new Point3D();
typeof
: returns a string representing the type of the operand (e.g.,'string'
,'number'
,'boolean'
,'object'
,'function'
, or'undefined'
)
const objType = typeof point; // 'object'
const isObject = typeof point === 'object'; // true
instanceof
: checks whether an object is an instance of a specific class
const isPoint2D = point instanceof Point2D; // true
const isPoint2D = point instanceof Point3D; // true
const isString = point instanceof String; // false
Notice: it only works for objects and cannot be used to check primitive types like number
, string
, or boolean
.
const num: any = 4;
const isNumber = num instanceof Number; // false 😔
is
: used as a function return type
function isPoint3D(value: any): value is Point3D {
return value.z !== undefined;
}
The function returns a boolean
value, but TypeScript understands that if true
, the argument has a more specific type
const anyObject: unknown = point;
anyObject.getZ() // '.getZ()' is not available
const isPoint = isPoint3D(anyObject); // true
if (isPoint) {
anyObject.getZ(); // '.getZ()' is available. TS knows it's a Point3D
}
anyObject.getZ(); // '.getZ()' is not available in this scope again
in
: checks whether a property exists in an object
const getZExist = 'getZ' in point; // true
const xExist = 'x' in point; // true
const testExist = 'test' in point; // false
keyof
: obtains the union of keys of a given type
// TypeScript only allows to assign ('x','y', 'z', 'getZ')
const keys: keyof Point3D; = 'x';
it can be used as a generic constraint:
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getValue(point, 'z'); // OK
getValue(point, 'test'); // compilation Error, 'test' invalid key of Point3D
or even define types with it:
// create union type ('x','y', 'z', 'getZ')
type Point3DKeys = keyof Point3D;
const keys: Point3DKeys; = 'x';
// create mapped type
/*
type:
{
readonly x: number;
readonly y: number;
readonly z: number;
readonly getZ = () => number;
}
*/
type ReadonlyPoint3D = {
readonly [K in keyof Point3D]: Point3D[K];
};
const point: ReadonlyPoint3D = new Point3D();
point.x = 4; // Error, type is readonly
interface VS type alias
While in other languages interfaces are used to declare behavior for a hierarchy of classes, in TypeScript it is common to declare DTOs with it:
interface UserDto {
name: string;
age: number;
}
In fact, you can do the same with type
:
type UserDto = {
name: string;
age: number;
}
They both can be empty:
interface UserDto { }
type UserDto = {};
They both can leverage from utility types:
interface EmptyUserDto extends Omit<UserDto, 'name' | 'age'> { }
type EmptyUserDto = Omit<UserDto, 'name' | 'age'>;
They support inheritance in one way or another:
interface PersonDto extends UserDto { }
interface AdminDto extends UserDto {
role: string;
}
type PersonDto = UserDto;
type AdminDto = UserDto & {
role: string;
}
And can be implemented by classes:
interface IUser {
name: string;
}
class User implements IUser {
name: string = 'John';
}
type TUser = {
name: string;
}
class User implements TUser {
name: string = 'John';
}
The only two noticeable differences, except syntax, I can see.
interface
must have statically known properties whiletype
doesn’t:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
- TS will “merge” multiple interface declarations into one.
interface UserDto {
name: string;
age: number;
}
interface UserDto {
role: string;
}
// need to implement all properties
const user: UserDto = {
name: 'John',
age: 18,
role: 'admin',
}
This can be useful when you want to extend third-party code, but it can also be dangerous when someone unintentionally introduces conflicts or unexpected behavior.
The question remains, which one you choose? 🤔
Since can be used interchangeably, this is a matter of preferences or the project’s convention. Some prefer type
only for type aliases. The others use interfaces
only in a ‘classic’ OOP sense to describe a hierarchy of classes. There is no right choice.
I am still on interfaces because my old brain cannot process those new things 🙃.
Сonclusion
As you can see, regardless of how helpful TS is, it has its own flaws and weaknesses. Are you also one of those who spend time learning it, just to realize it is a superset of JavaScript 😅.
💬 Let me know in the comments, how often you struggle with those TS features. What other topics are worth mentioning? Are you “team type” or “team interfaces”? Let’s chat
✅ Subscribe
⬇️ Check out my other articles
☕️ Support me with a link below
👏 Don’t forget to clap
🙃 And stay awesome