Skip to main content
Advertisement

2.2 Special Types

TypeScript has four special types that do not exist in JavaScript: any, unknown, never, and void. Each deals with a different boundary of the type system. any intentionally abandons type safety, unknown accepts all values in a safe way, never represents unreachable code, and void represents functions with no return value. Understanding and correctly using these four types is the foundation of robust TypeScript code.


any — Abandoning Type Safety

any is an escape hatch that completely turns off TypeScript's type checking for a given variable. Any value can be assigned to an any variable, and any method or property can be accessed on it. The compiler does not check any of this.

let value: any = 42;
value = "hello"; // OK
value = true; // OK
value = { x: 1 }; // OK
value = [1, 2, 3]; // OK

value.toUpperCase(); // no compile error — may fail at runtime
value.nonExistentMethod(); // no compile error — TypeError at runtime
value[99].deep.call(); // no compile error — disaster at runtime

How any Spreads

The most dangerous characteristic of any is spreading. When you assign an any value to another variable, that variable also becomes any.

function parseData(raw: any) {
const name = raw.name; // name is any
const age = raw.age + 1; // age is also any — type safety completely gone
return { name, age }; // return type is also { name: any, age: any }
}

const result = parseData({ name: "Alice", age: 30 });
result.name.toUpperCase(); // no compile error
result.name.nonExistent(); // no compile error — error at runtime

When You Have No Choice

There are real-world situations where avoiding any entirely is difficult.

// 1. When a legacy JS library has no type definitions (@types/*)
declare const legacyLib: any;
legacyLib.initialize({ debug: true });

// 2. Incremental migration — an intermediate step when moving large JS codebases to TS
// (should be replaced with unknown in the long run)
function migrateOldFunction(data: any): any {
// TODO: type definitions to be added later
return data;
}

// 3. Dynamic JSON deserialization — can be replaced with unknown immediately
const parsed = JSON.parse(rawString); // return type is any

Strategies to Minimize any

// Strategy 1: Replace with unknown and add type guards
function process(value: unknown): string {
if (typeof value === "string") return value.toUpperCase();
if (typeof value === "number") return value.toString();
return String(value);
}

// Strategy 2: Replace with generics to maintain type safety
function identity<T>(value: T): T {
return value;
}

// Strategy 3: Set ESLint @typescript-eslint/no-explicit-any rule to warn
// Enabling it in eslint.config.js lets you track any usage across the codebase

// Strategy 4: noImplicitAny: true (tsconfig.json) — ban implicit any
// function bad(x) { return x; } // error: Parameter 'x' implicitly has an 'any' type

unknown — The Safe Alternative to any

unknown means "the type is not yet known." It can accept all values like any, but the key difference is that the value cannot be used until the type is confirmed. For external data, API responses, and user input where the type cannot be known in advance, you must use unknown instead of any.

let data: unknown = fetchFromApi();

// Direct use causes a compile error
// data.toUpperCase(); // error: Object is of type 'unknown'
// const len = data.length; // error: Object is of type 'unknown'
// data(); // error: Object is of type 'unknown'

// Can only be used after passing a type guard
if (typeof data === "string") {
console.log(data.toUpperCase()); // OK — data is string in this block
}

if (Array.isArray(data)) {
console.log(data.length); // OK — data is unknown[] in this block
}

Core Difference: unknown vs any

let anyVal: any = "hello";
let unknownVal: unknown = "hello";

// any can be assigned to other types without type checking
let str1: string = anyVal; // OK — dangerous!

// unknown cannot be assigned to other types without type confirmation
let str2: string = unknownVal; // error: Type 'unknown' is not assignable to type 'string'

// Allowed after a type guard
if (typeof unknownVal === "string") {
let str3: string = unknownVal; // OK
}

Types of Type Guards

Various type guard patterns exist for narrowing the unknown type.

function narrow(value: unknown): void {
// typeof guard
if (typeof value === "string") {
console.log(value.toUpperCase());
}

// instanceof guard
if (value instanceof Date) {
console.log(value.toISOString());
}

// Array.isArray guard
if (Array.isArray(value)) {
console.log(value.length);
}

// User-defined type guard
if (isUser(value)) {
console.log(value.name);
}
}

interface User {
id: number;
name: string;
}

function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
typeof (value as User).id === "number" &&
typeof (value as User).name === "string"
);
}

Using unknown for Function Return Values

// JSON.parse returns any, but a wrapper function can force it to return unknown
function safeJsonParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return null;
}
}

const parsed = safeJsonParse('{"name":"Alice","age":30}');

// Cannot use directly — type guard is required
// parsed.name; // error

if (isUser(parsed)) {
console.log(parsed.name); // "Alice"
console.log(parsed.id); // undefined at runtime (not in the data) — the type is number, so be careful
}

never — Unreachable Code

never is the type of values that can never exist. It appears when a function always throws an exception or runs an infinite loop and never returns normally, or when all possible types have been narrowed away.

Functions That Always Throw

function fail(message: string): never {
throw new Error(message);
}

function assertUnreachable(x: never): never {
throw new Error(`Reached unreachable code: ${JSON.stringify(x)}`);
}

Infinite Loops

function runForever(): never {
while (true) {
// event loop or server main loop
}
}

Exhaustive Checks

The most powerful use of never is verifying the completeness of a discriminated union at compile time. If a new case is added but not handled in a switch statement, a type error will catch the omission.

type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };

function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// shape should be never here
// if a new type is added to Shape without handling it, a compile error occurs
return assertUnreachable(shape);
}
}

If a new shape "ellipse" is added:

type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number }
| { kind: "ellipse"; rx: number; ry: number }; // added

// In getArea's default, shape is now { kind: "ellipse"; rx: number; ry: number }
// assertUnreachable(shape) errors: Argument of type '{ kind: "ellipse"; ... }' is not
// assignable to parameter of type 'never'
// → the compiler tells you that the "ellipse" case hasn't been handled

never and Conditional Types

never is also used to filter out certain types in conditional types.

// never is removed from unions: string | never = string
type NonNullable<T> = T extends null | undefined ? never : T;

type A = NonNullable<string | null | undefined>; // string
type B = NonNullable<number | null>; // number

void — No Return Value

void is used primarily as the return type of functions that return no value. In JavaScript, functions with no explicit return statement implicitly return undefined; TypeScript expresses this intent with void.

function logMessage(message: string): void {
console.log(`[LOG] ${message}`);
// return; // OK — return with nothing
// return undefined; // OK
// return "hello"; // error: Type 'string' is not assignable to type 'void'
}

The Difference Between void and undefined

void and undefined look similar but have an important difference.

// The result of a void-returning function can be stored in a variable, but its type is void
const result: void = logMessage("test");
// Using result is meaningless

// In callback types, void means "the return value is ignored"
type Callback = () => void;

const cb: Callback = () => "hello"; // OK! return values are allowed but ignored
const arr = [1, 2, 3];
arr.forEach((n) => n * 2); // forEach callback return type is void — return value ignored

The Difference Between void and undefined in Function Type Declarations

// Function type with void return — having a return value is compile-allowed
type VoidFn = () => void;
const voidFn: VoidFn = () => 42; // OK

// Function with undefined return — must return undefined
type UndefFn = () => undefined;
const undefFn: UndefFn = () => 42; // error: Type 'number' is not assignable to type 'undefined'
const undefFn2: UndefFn = () => undefined; // OK

Because of this difference, the callback types for Array.prototype.forEach and Array.prototype.map use void. The intent is that whatever the callback returns will be ignored.


Practical Example 1: Using unknown in API Error Handling

When calling an external API, the response format cannot be known in advance. This pattern safely handles it by combining unknown with type guards.

// Define API response shapes
interface ApiSuccessResponse<T> {
status: "success";
data: T;
}

interface ApiErrorResponse {
status: "error";
code: number;
message: string;
}

type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

// Type guards
function isApiError(response: unknown): response is ApiErrorResponse {
return (
typeof response === "object" &&
response !== null &&
"status" in response &&
(response as ApiErrorResponse).status === "error" &&
"code" in response &&
"message" in response
);
}

function isApiSuccess<T>(
response: unknown,
dataValidator: (data: unknown) => data is T
): response is ApiSuccessResponse<T> {
return (
typeof response === "object" &&
response !== null &&
"status" in response &&
(response as ApiSuccessResponse<T>).status === "success" &&
"data" in response &&
dataValidator((response as ApiSuccessResponse<T>).data)
);
}

// User data type and validation
interface UserData {
id: number;
name: string;
email: string;
}

function isUserData(data: unknown): data is UserData {
return (
typeof data === "object" &&
data !== null &&
typeof (data as UserData).id === "number" &&
typeof (data as UserData).name === "string" &&
typeof (data as UserData).email === "string"
);
}

// API call function — forces return type to unknown
async function fetchUser(id: number): Promise<unknown> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // json() returns Promise<any> — wrapped as unknown
}

async function getUser(id: number): Promise<UserData> {
const raw = await fetchUser(id);

if (isApiError(raw)) {
throw new Error(`API error ${raw.code}: ${raw.message}`);
}

if (isApiSuccess(raw, isUserData)) {
return raw.data;
}

throw new Error("Unexpected response format");
}

// Actual usage
async function main() {
try {
const user = await getUser(1);
console.log(`User: ${user.name} (${user.email})`);
} catch (error) {
// error is of type unknown (TypeScript 4.0+)
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
} else {
console.error("Unknown error");
}
}
}

Practical Example 2: Discriminated Union Exhaustive Check

// Notification system — sends messages over multiple channels
type Notification =
| { channel: "email"; to: string; subject: string; body: string }
| { channel: "sms"; to: string; message: string }
| { channel: "push"; deviceToken: string; title: string; body: string }
| { channel: "slack"; webhookUrl: string; text: string };

function assertNever(x: never): never {
throw new Error(`Unhandled notification channel: ${(x as { channel: string }).channel}`);
}

function sendNotification(notification: Notification): Promise<void> {
switch (notification.channel) {
case "email":
return sendEmail(notification.to, notification.subject, notification.body);
case "sms":
return sendSms(notification.to, notification.message);
case "push":
return sendPush(notification.deviceToken, notification.title, notification.body);
case "slack":
return sendSlack(notification.webhookUrl, notification.text);
default:
// If all cases are handled, notification is never here
// Adding a new channel will immediately cause a compile error for the omission
return assertNever(notification);
}
}

// Stub implementations
declare function sendEmail(to: string, subject: string, body: string): Promise<void>;
declare function sendSms(to: string, message: string): Promise<void>;
declare function sendPush(token: string, title: string, body: string): Promise<void>;
declare function sendSlack(url: string, text: string): Promise<void>;

Pro Tips

Tip 1: Handle the error in catch blocks as unknown

From TypeScript 4.0, with the useUnknownInCatchVariables: true setting, the error in catch blocks is of type unknown. This is included in strict: true.

function riskyOperation(): void {
try {
JSON.parse("invalid json");
} catch (error) {
// error is unknown
if (error instanceof Error) {
console.error(`Error message: ${error.message}`);
console.error(`Stack: ${error.stack}`);
} else if (typeof error === "string") {
console.error(`String error: ${error}`);
} else {
console.error("Unknown error form:", error);
}
}
}

Tip 2: Use never for type filtering

// Utility type that removes a specific type from a union
type Exclude<T, U> = T extends U ? never : T;

type WithoutString = Exclude<string | number | boolean, string>;
// Result: number | boolean

type NonNullable<T> = T extends null | undefined ? never : T;

type SafeString = NonNullable<string | null | undefined>;
// Result: string

Tip 3: Always document the reason when using any with a comment

// Documenting the reason for using any in this form makes it easy to understand context when improving later
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const legacyCallback: any = getLegacyHandler();
// TODO: remove when @types/legacy-lib package is released (issue #123)

Tip 4: Build a deserialization pipeline with unknown

function parseConfig(raw: string): AppConfig {
const parsed: unknown = JSON.parse(raw);
return validateConfig(parsed); // validation function handles unknown → AppConfig conversion
}

function validateConfig(raw: unknown): AppConfig {
if (!isAppConfig(raw)) {
throw new Error("Invalid config file format");
}
return raw;
}

// isAppConfig is safer when generated by a runtime validation library like zod, yup, or io-ts
declare function isAppConfig(value: unknown): value is AppConfig;
interface AppConfig { port: number; dbUrl: string; }

Tip 5: Explicitly declare void return types to convey callback intent

// Marking event handlers as void makes it clear that return values are meaningless
type EventHandler<T extends Event> = (event: T) => void;

const onClick: EventHandler<MouseEvent> = (e) => {
e.preventDefault();
console.log(e.clientX, e.clientY);
// return "ignored"; // having a return value is allowed — the meaning of void
};

Comparison Table

TypeAssignable ValuesUsable DirectlyPrimary Use
anyAll valuesUnrestricted (type checking off)Legacy migration, temporary escape hatch
unknownAll valuesOnly after passing a type guardExternal data, API responses, catch blocks
neverNoneImpossible (value cannot exist)Exhaustive checks, always-throwing functions
voidundefined (+ null in non-strict)LimitedFunctions with no return value, callback return types
Comparisonanyunknown
All types assignable to itOO
Directly assignable to other typesOX (type guard required)
Direct method/property accessOX (type guard required)
Type safetyNonePresent
RecommendedMinimizeUse actively

The next chapter covers arrays and tuples for grouping and managing multiple values. We will look at how to guarantee immutability with readonly arrays, labeled tuples, variadic tuple elements, and the pattern of locking in literal types with as const.

Advertisement