bastianplsfix

any, unknown, and never

If a type is a set of values, as what a type is established, then these three are the extremes. any and unknown are both the set of everything. never is the empty set.

The two wide ones differ in one respect, and it decides everything: with unknown, values go in and nothing comes out until you prove what you have. With any, values go in and come out again unchallenged. That makes unknown a boundary and any a hole, and this page spends its first half proving the difference.

Create programs/any-unknown-never.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

anything goes into unknown, and nothing comes out

unknown is the set of everything, so every assignment into it is a membership success. Assignment out of it is another story. Try both directions and save:

Deno.test("anything goes into unknown, and nothing comes out", () => {
let anything: unknown;
anything = null;
anything = true;
anything = { total: 10 };
assertEquals(anything, { total: 10 });

const value: unknown = "oak";
const text: string = value;
assertEquals(text, "oak");
});
Check programs/any-unknown-never.test.ts
TS2322 [ERROR]: Type 'unknown' is not assignable to type 'string'.
const text: string = value;
~~~~
at file:///programs/any-unknown-never.test.ts:12:11

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Both directions, one rule.

  1. null, true, and an object all flow into anything without complaint, because every value is a member of the set of everything.
  2. Flowing out fails, and the reason is the same subset logic running in reverse. The target type of text accepts members of string, and an unknown could be holding anything at all, so the checker cannot prove membership and refuses.

The value in question really is a string, so the refusal can be pinned as the claim it is:

Deno.test("anything goes into unknown, and nothing comes out", () => {
let anything: unknown;
anything = null;
anything = true;
anything = { total: 10 };
assertEquals(anything, { total: 10 });

const value: unknown = "oak";
// @ts-expect-error: unknown satisfies no other type until narrowed
const text: string = value;
assertEquals(text, "oak");
});
Check programs/any-unknown-never.test.ts
running 1 test from ./programs/any-unknown-never.test.ts
anything goes into unknown, and nothing comes out ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

any is the same set with the checking turned off

any also accepts everything. The difference is what happens on the way out:

Deno.test("any is the same set with the checking turned off", () => {
// deno-lint-ignore no-explicit-any
const loose: any = "oak";
const count: number = loose;
assertEquals(typeof count, "string");
});
any is the same set with the checking turned off ... ok (0ms)

Read what just happened, because the quietness is the event.

  1. loose holds a string, and it flows into count, whose target type is number, with no diagnostic produced anywhere.
  2. The runtime assertion confirms the damage: typeof count is "string". A string is sitting in a variable annotated number, and the file checks clean.
  3. That is not a bug in TypeScript. any means "stop checking", and it did. An unknown satisfies no other type; an any satisfies every type, so every target type accepts it.

One more voice in the room: the deno-lint-ignore line exists because Deno's linter refuses an explicit any by default, through the no-explicit-any rule. To write one at all, you sign for it on the line above, which is exactly the right amount of friction for a hole.

the hole in one line: JSON.parse returns any

JSON.parse returns any, because what it returns genuinely depends on the text you gave it. Feed it a string while the annotation says number, predict what typeof reports, and save:

Deno.test("the hole in one line: JSON.parse returns any", () => {
const total: number = JSON.parse('"not a number"');
assertEquals(typeof total, "number");
assertThrows(() => total.toFixed(2), TypeError);
});
Check programs/any-unknown-never.test.ts
running 3 tests from ./programs/any-unknown-never.test.ts
anything goes into unknown, and nothing comes out ... ok (1ms)
any is the same set with the checking turned off ... ok (0ms)
the hole in one line: JSON.parse returns any ... FAILED (8ms)

ERRORS

the hole in one line: JSON.parse returns any => ./programs/any-unknown-never.test.ts:24:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- string
+ number

FAILURES

the hole in one line: JSON.parse returns any => ./programs/any-unknown-never.test.ts:24:11

FAILED | 2 passed | 1 failed (10ms)

error: Test failed

total is a string. The annotation promised number, JSON.parse's any waved the string through, and the checker saw nothing wrong with any of it. Correct the prediction to "string" and keep the assertThrows, which shows where this ends in a real program: something eventually calls a number method on the value, and the program dies far from the line that lied.

the hole in one line: JSON.parse returns any ... ok (0ms)

Every argument for unknown is this example. JSON.parse predates unknown, which is the only reason its signature still says any. Treat its result as unknown yourself: assign it to an unknown and narrow, the way a predicate teaches the checker a check of your own did on the unions and narrowing page, or hand it to a validator that returns a real type.

Deno will not give you an implicit any

Historically, any also arrived uninvited: a parameter without a type silently became one. Under Deno's defaults that is an error instead:

Deno.test("Deno will not give you an implicit any", () => {
// @ts-expect-error: a parameter without a type is an error, not an any
function greet(name): string {
return `Hello ${name}`;
}
assertEquals(greet("world"), "Hello world");
});
Deno will not give you an implicit any ... ok (0ms)

The pinned diagnostic is TS7006: Parameter 'name' implicitly has an 'any' type. The consequence is worth stating in full: the any values in your program are the ones you wrote on purpose, each carrying its lint-ignore signature, plus the ones arriving from library signatures like JSON.parse. Auditing them is a finite job, which is a much better position than it sounds.

the honest way out of unknown is a question

An unknown is unusable on purpose, and there are two ways out. The honest one is to ask a question the checker understands:

Deno.test("the honest way out of unknown is a question", () => {
const value: unknown = "oak";
// @ts-expect-error: unknown is unusable until narrowed
const eager = value.length;
assertEquals(eager, 3);

if (typeof value === "string") {
assertEquals(value.length, 3);
}
});
the honest way out of unknown is a question ... ok (0ms)

Two reads of the same property, two verdicts.

  1. The eager read is pinned: TS18046: 'value' is of type 'unknown'. The runtime line underneath shows the read works this time, because the value happens to be a string, which is precisely the kind of luck the checker refuses to rely on.
  2. Inside the typeof guard, value has narrowed from unknown to string, and the same read is legal. Everything the unions-and-narrowing page's the checks that narrow are ordinary JavaScript established applies here: typeof, instanceof, Array.isArray, literal comparison, a predicate, an assertion function. What you learn this way is checked.

as is a claim, not a conversion

The second way out is to assert, with as, that you know better. The checker resists when the two types share no members. Try it directly:

Deno.test("as is a claim, not a conversion", () => {
const direct = "oak" as number;
assertEquals(typeof direct, "string");
});
Check programs/any-unknown-never.test.ts
TS2352 [ERROR]: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
const direct = "oak" as number;
~~~~~~~~~~~~~~~
at file:///programs/any-unknown-never.test.ts:50:20

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Read the whole message, because its last sentence is remarkable: If this was intentional, convert the expression to unknown first. The checker tells you how to defeat it. Take the suggestion and watch what it buys:

Deno.test("as is a claim, not a conversion", () => {
const value: unknown = "oak";
const doubled = value as number;
assertThrows(() => doubled.toFixed(2), TypeError);
});
as is a claim, not a conversion ... ok (0ms)

value as number compiles, because unknown converts to anything, and the assertThrows records what the claim was worth: the first number method call crashes. That is the honest summary of as. It is not a conversion and it performs no check; it is a claim taken on trust, and a wrong one buys a crash later instead of an error now. Every as unknown as X these references have written, starting back in the values-and-references page, is this maneuver twice over, and it should always feel expensive.

never vanishes from a union, and unknown absorbs one

never is the empty set, and its algebra follows from that. Type-level claims need type-level evidence, and assignability in both directions is the tool: two sets that each fit inside the other are the same set.

Deno.test("never vanishes from a union, and unknown absorbs one", () => {
type WithNever = "a" | "b" | never;
const forward: WithNever = "a";
const backward: "a" | "b" = forward;
assertEquals(backward, "a");

const anything: unknown = 42;
const absorbed: unknown | string = anything;
assertEquals(absorbed, 42);
});
never vanishes from a union, and unknown absorbs one ... ok (0ms)

Two identities, proved by flow.

  1. forward carries a WithNever into a variable of plain type "a" | "b", and the checker accepts, so WithNever fits inside "a" | "b". The other direction is trivial. Adding the empty set to a union contributed no members, so never vanished from it.
  2. absorbed accepts a bare unknown holding 42, a number, not a string, so unknown | string accepts everything unknown does. Adding everything to a set absorbs it: the union is just unknown.

the checker answers never when no value could qualify

never also appears where you never wrote it, as the checker's way of saying "no value could satisfy this". Two constructions force its hand:

Deno.test("the checker answers never when no value could qualify", () => {
type NoKeys = keyof {};
// @ts-expect-error: an empty object has no keys, so keyof gives never
const key: NoKeys = "a";
assertEquals(String(key), "a");

type Impossible = boolean & symbol;
// @ts-expect-error: no value is both a boolean and a symbol
const collapsed: Impossible = true;
assertEquals(collapsed, true);
});
the checker answers never when no value could qualify ... ok (0ms)

Neither declaration mentions never, and both pinned diagnostics do.

  1. Assigning to NoKeys draws Type '"a"' is not assignable to type 'never'. keyof {} asks for the set of keys of an object with no properties, and the set of no keys is the empty set.
  2. Assigning to Impossible draws Type 'true' is not assignable to type 'never'. Intersecting boolean and symbol asks for values belonging to both sets, and no value does, so the intersection collapses to empty. When never turns up in an error message you did not expect, this is usually what happened: somewhere, an intersection or a lookup produced a set with nothing in it.

nothing is assignable to never

The defining property of the empty set is that membership always fails, which makes never useful as a prohibition:

Deno.test("nothing is assignable to never", () => {
// @ts-expect-error: never is the empty set
const nothing: never = 1;
assertEquals(nothing, 1);

// @ts-expect-error: each value would have to come from the empty set
const forbidden: Record<string, never> = { total: 123 };
assertEquals(forbidden.total, 123);

const allowed: Record<string, never> = {};
assertEquals(Object.keys(allowed).length, 0);
});
nothing is assignable to never ... ok (0ms)

Three flows, one property.

  1. 1 cannot enter the empty set, and nothing else can either.
  2. Record<string, never> describes an object whose string keys each hold a value from the empty set, which is a precise way of saying "no properties". The object with total is refused; the empty object is the only member.
  3. This same property powers the exhaustiveness trick from the unions-and-narrowing page's never turns a forgotten case into a compile error: assigning a leftover case to never succeeds only when the checker has proved nothing is left.

a function typed never cannot return

The last home of never is a return type, where it promises the function will not finish normally: it throws, or it loops forever. The checker spends that promise on the code after the call:

Deno.test("a function typed never cannot return", () => {
function fail(message: string): never {
throw new Error(message);
}
function required(value: string | undefined): string {
if (value === undefined) fail("missing");
return value;
}
assertEquals(required("oak"), "oak");
assertThrows(() => required(undefined), Error, "missing");
});
a function typed never cannot return ... ok (1ms)

Look at required closely, because something is missing.

  1. There is no else and no early return, and yet return value type-checks with value narrowed to string.
  2. The narrowing works because fail returns never: control cannot come back from a function whose return set is empty, so any line after the call is reachable only when the call did not happen, and on that path value was not undefined.
  3. The two assertions exercise both paths: the string comes through, and the undefined ends in the promised throw.

Annotate your throw helpers never and they do this work at every call site. Annotate them void and every caller has to convince the checker separately.

In practice