Sitemap

Quirky things about TypeScript

13 min readNov 7, 2024

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 is null or undefined:
// 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 is null)
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 while type 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

--

--

iamprovidence
iamprovidence

Written by iamprovidence

👨🏼‍💻 Full Stack Dev writing about software architecture, patterns and other programming stuff https://www.buymeacoffee.com/iamprovidence

Responses (1)