In TypeScript, any vs unknown comes down to one question: does the compiler still check what you do with the value? Both types accept any value. any switches type checking off, so every property access and method call compiles, even ones that crash at runtime. unknown makes you prove what the value is, with a check such as typeof or instanceof, before you use it. For values whose type you don't know yet, use unknown.
That distinction sits on top of a few basics that are worth getting right early: primitive and literal types, how strictNullChecks changes null and undefined, the difference between void and never, and when to use type assertions and as const. This guide covers each one with examples for TypeScript 5.x.
Primitive types in TypeScript
TypeScript has the same primitives as JavaScript:
| Type | Example values | Notes |
|---|---|---|
string | "hello", `id-${n}` | |
number | 42, 3.14, NaN | Integers and floats share one type |
bigint | 9007199254740993n | Needs target ES2020 or later |
boolean | true, false | |
symbol | Symbol("id") | Unique keys |
null | null | Intentional absence of a value |
undefined | undefined | Not assigned yet |
Use the lowercase names. String, Number and Boolean are wrapper object types and almost never what you want.
You rarely need to annotate a local variable, because TypeScript infers it. Annotate function parameters, return types of exported functions, and variables that start empty:
let count = 0; // inferred as number
const ids: string[] = []; // empty array needs an annotation
function total(prices: number[]): number {
return prices.reduce((sum, p) => sum + p, 0);
}Literal types: how let and const infer differently
A literal type is a type with exactly one value, such as "GET" or 404. The keyword you declare with decides whether TypeScript keeps the literal or widens it:
let a = "GET"; // string: a let can be reassigned
const b = "GET"; // "GET": a const can never change
var c = 404; // numberLiteral types become useful in unions, where they act like a lightweight enum:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function request(url: string, method: HttpMethod) { /* ... */ }
request("/users", "GET"); // OK
request("/users", "FETCH"); // Error: '"FETCH"' is not assignable to parameter of type 'HttpMethod'Unions of literals are covered in more depth, together with discriminated unions and template literal types, in TypeScript advanced types.
strictNullChecks: making null and undefined explicit
Without strictNullChecks, null and undefined are assignable to every type, so string quietly means "string, or maybe nothing". With it enabled, you have to say so:
{
"compilerOptions": {
"strict": true
}
}"strict": true turns on strictNullChecks along with noImplicitAny and several other checks. Enable it in every new project. The TypeScript project setup guide walks through a full tsconfig.json.
With strict null checks, the compiler catches the classic "cannot read properties of undefined" crash:
interface User { id: number; name: string }
function findUser(id: number): User | undefined {
return users.find((u) => u.id === id);
}
const user = findUser(1);
console.log(user.name); // Error: 'user' is possibly 'undefined'
console.log(user?.name); // OK: string | undefined
const name = user?.name ?? "Guest"; // OK: stringOptional chaining (?.) stops at null or undefined, and nullish coalescing (??) supplies a fallback. The non-null assertion user!.name also compiles, but it only silences the error. Use it only when you know something the compiler can't.
any vs unknown in TypeScript
Both types accept any value. The difference is what you can do with it afterwards.
let a: any = "hello";
a.toFixed(2); // compiles, then throws: a.toFixed is not a function
const n: number = a; // compiles: any is assignable to everything
let u: unknown = "hello";
u.toFixed(2); // Error: 'u' is of type 'unknown'
const m: number = u; // Error: 'unknown' is not assignable to 'number'
if (typeof u === "string") {
console.log(u.toUpperCase()); // OK: narrowed to string
}any | unknown | |
|---|---|---|
| Accepts any value | Yes | Yes |
| Property access and calls | Allowed, unchecked | Error until narrowed |
| Assignable to other types | Yes, to everything | Only to unknown and any |
| Spreads through your code | Yes, results are any too | No |
| Typical use | Migrating JavaScript, escape hatch | External input: JSON, API responses, catch errors |
The spreading is the real danger of any. a.user.profile is also any, and so is anything computed from it, so one any can silently switch off checking far from where it was introduced.
Narrowing unknown safely
You turn unknown into something usable by narrowing it: typeof, instanceof, Array.isArray, the in operator, or a type guard function.
JSON.parse returns any, so a good habit is to wrap it and validate the result:
interface Config {
port: number;
host: string;
}
function isConfig(value: unknown): value is Config {
return (
typeof value === "object" &&
value !== null &&
"port" in value && typeof value.port === "number" &&
"host" in value && typeof value.host === "string"
);
}
const raw: unknown = JSON.parse(text);
if (!isConfig(raw)) {
throw new Error("Invalid config file");
}
console.log(raw.port); // raw is Config hereFor larger payloads, a schema library such as Zod or Valibot does the same job with less code. Type guards and other narrowing techniques are covered in interfaces vs type aliases.
Errors in catch blocks are another common case. Under strict, the useUnknownInCatchVariables option types the caught value as unknown, because JavaScript lets you throw anything:
try {
await saveOrder(order);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(message);
}When any is acceptable
any still has uses: gradually migrating a JavaScript codebase, or working around a third-party type that is wrong. Keep it local, add a comment explaining why, and enable the @typescript-eslint/no-explicit-any lint rule so new ones get reviewed.
void vs never
Both describe functions that don't give you a useful value, but for different reasons.
voidmeans the function returns, but the return value shouldn't be used. At runtime it returnsundefined.nevermeans the function never returns normally. It always throws or loops forever.
function log(message: string): void {
console.log(message);
}
function fail(message: string): never {
throw new Error(message);
}never is also the type of a value that can't exist, which makes it useful for exhaustive checks. If someone adds a new member to a union and forgets a case, the assignment to never fails to compile:
type Status = "active" | "suspended" | "deleted";
function statusLabel(status: Status): string {
switch (status) {
case "active":
return "Active";
case "suspended":
return "Suspended";
case "deleted":
return "Deleted";
default: {
const unreachable: never = status;
throw new Error(`Unhandled status: ${unreachable}`);
}
}
}One void detail surprises people: a function type that returns void accepts functions that return a value. That's why items.forEach((x) => results.push(x)) compiles even though push returns a number. The return value is simply ignored.
Type assertions: telling the compiler what you know
A type assertion with as tells TypeScript to treat a value as a more specific type. It does nothing at runtime. There is no conversion and no check.
const input = document.getElementById("email") as HTMLInputElement;
input.value = "[email protected]";getElementById returns HTMLElement | null. The assertion says "I know this is an input and it exists". If you're wrong, the error shows up at runtime instead. A safer version keeps the null check:
const input = document.querySelector<HTMLInputElement>("#email");
if (input) input.value = "[email protected]";TypeScript only allows assertions between types that overlap. "hello" as number is an error. value as unknown as Target gets around that, and it is almost always a sign that the types need fixing instead.
When you want to check that a value matches a type without losing its more precise inferred type, use satisfies:
type Route = { path: string; auth: boolean };
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} satisfies Record<string, Route>;
routes.admin.path; // OK: TypeScript still knows the keys are "home" | "admin"With a type annotation (const routes: Record<string, Route>), the specific keys would be lost and routes.anything would compile.
const assertions with as const
as const tells TypeScript to infer the narrowest possible type: literal values instead of string or number, and readonly properties and tuples instead of mutable objects and arrays.
const req = { url: "/users", method: "GET" };
request(req.url, req.method);
// Error: 'string' is not assignable to parameter of type 'HttpMethod'
const req2 = { url: "/users", method: "GET" } as const;
request(req2.url, req2.method); // OK: method is "GET"Its most useful pattern is deriving a union type from a list of values, so the runtime array and the type can't drift apart:
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
function isRole(value: string): value is Role {
return (ROLES as readonly string[]).includes(value);
}An object with as const is also a common alternative to an enum.
FAQ
Should I use unknown or any?
Use unknown for values whose type you don't know yet, such as parsed JSON, API responses and caught errors. It forces a check before use. Reserve any for migrations and workarounds, and keep it local.
What is the difference between void and undefined?
undefined is a value and a type you can assign and check. void is used for return types and means "don't use the return value". A function returning void actually returns undefined at runtime, but the type makes the intent clear.
Is a type assertion the same as type casting?
No. Casting in languages like Java or C# can convert or check the value at runtime. A TypeScript assertion is erased when compiling and only changes what the compiler believes.
What does as const do in TypeScript?
It makes TypeScript infer literal types and readonly properties. ["a", "b"] as const has the type readonly ["a", "b"] instead of string[].
Does unknown have any runtime cost?
No. Like every TypeScript type, it disappears at compile time. The only runtime code is the checks you write to narrow it.
Basic types checklist
- Turn on
"strict": truein every project. - Let inference work for locals. Annotate parameters and public return types.
- Model small fixed sets of values as literal unions.
- Use
unknownfor untrusted input and narrow it with a type guard or schema. - Use
neverfor exhaustiveswitchchecks. - Prefer
satisfiesand null checks overas. - Use
as constto derive types from runtime values.
Once these basics are in place, TypeScript generics are the next step for writing reusable, type-safe functions. If your team is starting a TypeScript codebase and wants it set up well from day one, Vectorkub can help.
