Friday, 10 October 2025

TypeScript: any, unknown, never

G'day:

I've been using any and unknown in TypeScript for a while now - enough to know that ESLint hates one and tolerates the other, and that sometimes you need to do x as unknown as y to make the compiler shut up. But knowing they exist and actually understanding what they're for are different things.

The Jira ticket I set up for this was straightforward enough:

Both unknown and any represent values of uncertain type, but they have different safety guarantees. any opts out of type checking entirely, while unknown is type-safe and requires narrowing before use.

Simple, right? any turns off TypeScript's safety checks, unknown keeps them on. I built some examples, wrote some tests, and thought I was done.

Then Claudia pointed out I'd completely missed the point of type narrowing. I was using type assertions (value as string) instead of type guards (actually checking what the value is at runtime). Assertions just tell TypeScript to trust you. Guards actually verify you're right.

Turns out there's a difference between "making the compiler happy" and "writing safe code".

any - when you genuinely don't know or don't care

I started with a generic key-value object that could hold anything:

export type WritableValueObject = Record<string, any>
export type ValueObject = Readonly<WritableValueObject>

type keyValue = [string, any]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject => {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}

(from any.ts)

ESLint immediately flags every any with warnings about unsafe assignments and lack of type checking. But this is actually a legitimate use case - I'm building a container that genuinely holds arbitrary values. The whole point is that I don't know what's in there and don't need to.

The Readonly<...> wrapper makes it immutable after creation, which is what you want for a value object. Try to modify it and TypeScript complains about the index signature being readonly. The error message says Readonly<WritableValueObject> instead of just ValueObject because TypeScript helpfully expands type aliases in error messages. Sometimes this is useful (showing you what the type actually is), sometimes it's just verbose.

unknown - the safer alternative that's actually more annoying

The unknown version looks almost identical:

export type WritableValueObject = Record<string, unknown>
export type ValueObject = Readonly<WritableValueObject>

type keyValue = [string, unknown]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject => {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}

(from unknown.ts)

The difference shows up when you try to use the values. With any, you can do whatever you want:

const value = vo.someKey;
const reversed = reverse(value); // Works fine with any

With unknown, TypeScript blocks you:

const value = vo.someKey;
const reversed = reverse(value); // Error: 'value' is of type 'unknown'

My first solution was type assertions:

const reversed = reverse(value as string); // TypeScript: "OK, if you say so"

This compiles. The tests pass. I thought I was done.

Then Claudia pointed out I wasn't actually checking anything - I was just telling TypeScript to trust me. Type assertions are a polite way of saying "shut up, compiler, I know what I'm doing". Which is fine when you genuinely do know, but defeats the point of using unknown in the first place.

Type guards - actually checking instead of just asserting

The proper way to handle unknown is with type guards - runtime checks that prove what type you're dealing with. TypeScript then narrows the type based on those checks.

The simplest is typeof:

const theWhatNow = returnsAsUnknown(input);

if (typeof theWhatNow === 'string') {
  const reversed = reverse(theWhatNow); // TypeScript knows it's a string now
}

(from unknown.test.ts)

Inside the if block, TypeScript knows theWhatNow is a string because the typeof check proved it. Outside that block, it's still unknown.

For objects, use instanceof:

const theWhatNow = returnsAsUnknown(input);

if (theWhatNow instanceof SomeClass) {
  expect(theWhatNow.someMethod('someValue')).toEqual('someValue');
}

And for custom checks, you can write type guard functions with the is predicate:

export class SomeClass {
  someMethod(someValue: unknown): unknown {
    return someValue
  }

  static isValid(value: unknown): value is SomeClass {
    return value instanceof SomeClass
  }
}

(from unknown.ts)

The value is SomeClass return type tells TypeScript that if this function returns true, the value is definitely a SomeClass:

if (SomeClass.isValid(theWhatNow)) {
  expect(theWhatNow.someMethod('someValue')).toEqual('someValue');
}

This is proper type safety - you're checking at runtime, not just asserting at compile time.

Error handling with unknown

The most practical use of unknown is in error handling. Before TypeScript 4.0, everyone wrote:

try {
  throwSomeError('This is an error')
} catch (e) {  // e is implicitly 'any'
  console.log(e.message)  // Hope it's an Error!
}

Now you can (and should) use unknown:

try {
  throwSomeError('This is an error')
} catch (e: unknown) {
  expect(e).toBeInstanceOf(SomeError)
}

(from unknown.test.ts)

Catches can throw anything - not just Error objects. Someone could throw a string, a number, or literally anything. Using unknown forces you to check what you actually caught before using it.

So which one should you use?

Here's the thing though - for my ValueObject use case, unknown is technically safer but practically more annoying. The whole point of a generic key-value store is that you don't know what's in there. Making users narrow types every time they retrieve a value is tedious:

const value = getValueForKey(vo, 'someKey');
if (typeof value === 'string') {
  doSomething(value);
}

versus just:

const value = getValueForKey(vo, 'someKey');
doSomething(value as string);

For a genuinely generic container where you're accepting "no idea what this is" as part of the design, any is the honest choice. You're not pretending to enforce safety on truly dynamic data.

But for error handling, function parameters that could be anything, or situations where you'll actually check the type before using it, unknown is the better option. It forces you to handle the uncertainty explicitly rather than hoping for the best.

never - the type that can't exist

While any and unknown are about values that could be anything, never is about values that can't exist at all. It's the bottom type - nothing can be assigned to it.

The most obvious use is functions that never return:

export function throwAnError(message: string): never {
  throw new Error(message)
}

(from never.ts)

Functions that throw or loop forever return never because they don't return at all. TypeScript uses this to detect unreachable code:

expect(() => {
  throwAnError('an error')
  // "Unreachable code detected."
  const x: string = ''
  void x
}).toThrow('an error')

(from never.test.ts)

The const x line gets flagged because TypeScript knows the previous line never returns control.

Things get more interesting with conditional never:

export function throwsAnErrorIfItIsBad(message: string): boolean | never {
  if (message.toLowerCase().indexOf('bad') !== -1) {
    throw new Error(message)
  }
  return false
}

The return type says "returns a boolean, or never returns at all". TypeScript doesn't flag unreachable code after calling this function because it might actually return normally.

Exhaustiveness checking

The clever use of never is exhaustiveness checking in type narrowing:

export function returnsStringsOrNumbers(
  value: string | number
): string | number {
  if (typeof value === 'string') {
    const valueToReturn = value + ''
    return valueToReturn
  }
  if (typeof value === 'number') {
    const valueToReturn = value * 1
    return valueToReturn
  }
  const valueToReturn = value // TypeScript hints: const valueToReturn: never
  return valueToReturn
}

(from never.ts)

After checking for string and number, TypeScript knows that value can't be anything else, so it infers the type as never. This is TypeScript's way of saying "we've handled all possible cases".

If you tried to call the function with something that wasn't a string or number (like an array cast to unknown then to string), TypeScript won't catch it at compile time because you've lied to the compiler. But at least the never hint shows you've exhausted the legitimate cases.

The actual lesson

I went into this thinking I understood these types well enough - any opts out, unknown is safer, never is for functions that don't return. All true, but missing the point.

The real distinction is between compile-time assertions and runtime checks. Type assertions (as string) tell TypeScript "trust me", but they don't verify anything. Type guards (typeof, instanceof, custom predicates) actually check at runtime.

For genuinely dynamic data like a generic ValueObject, any is the honest choice - you're accepting the lack of type safety as part of the design. For cases where you'll actually verify the type before using it (like error handling), unknown forces you to be explicit about those checks.

And never is TypeScript's way of tracking control flow and exhaustiveness, which is useful when you actually pay attention to what it's telling you.

The code for all this is in the learning-typescript repository, with test examples showing the differences between assertions and guards. Thanks to Claudia for pointing out I was doing type assertions instead of actual type checking - turns out there's a difference between making the compiler happy and writing safe code.

Righto.

--
Adam