Skip to content

Instantly share code, notes, and snippets.

@trvswgnr
Created February 7, 2026 02:29
Show Gist options
  • Select an option

  • Save trvswgnr/561778f812d37d52adafe84c011c3cd0 to your computer and use it in GitHub Desktop.

Select an option

Save trvswgnr/561778f812d37d52adafe84c011c3cd0 to your computer and use it in GitHub Desktop.
typescript `never` on objects weird behavior
// --- Type-level Utilities for Testing --- //
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
const discard = <T>(x: T): void => void x;
const getRandomValue = (): string | number | bigint | boolean | symbol | undefined => {
const choices = ["foo", 1, 2n, true, Symbol("bar"), undefined];
const index = Math.floor(Math.random() * choices.length);
return choices[index];
};
type Die = (message: string) => never;
interface Utils {
die: Die;
}
{
// --- CASE 1: create utils object directly (no explicit return type) --- //
const utils: Utils = {
die: (m) => {
throw new Error(m);
},
};
type _Test_utils_die_returns_never = Expect<Equal<ReturnType<typeof utils.die>, never>>;
const x = getRandomValue();
if (typeof x !== "string") {
utils.die("x is not a string");
}
type _Test_x_narrows_to_string = Expect<Equal<typeof x, string>>;
discard<string>(x);
}
// --- NON-WORKING PATTERNS (commented out) ---
/*
{
// --- CASE 2: function that creates utils object --- //
// FAILS: Destructured `die` from factory doesn't narrow
type CreateUtils = () => Utils;
const createUtils: CreateUtils = () => {
const utils: Utils = {
die: (m) => {
throw new Error(m);
},
};
return utils;
};
const { die } = createUtils();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof die>, never>>;
const x = getX();
if (typeof x !== "string") {
die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
{
// --- CASE 3: function that creates utils, NO explicit type on variable --- //
// FAILS: Even with property access, no explicit type annotation = no narrowing
type CreateUtils = () => Utils;
const createUtils: CreateUtils = () => {
const utils: Utils = {
die: (m) => {
throw new Error(m);
},
};
return utils;
};
const utils = createUtils(); // <-- Missing `: Utils` annotation
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof utils.die>, never>>;
const x = getX();
if (typeof x !== "string") {
utils.die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
*/
{
// --- CASE 4: Explicitly typed standalone die function --- //
const die: Die = (m) => {
throw new Error(m);
};
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof die>, never>>;
const x = getRandomValue();
if (typeof x !== "string") {
die("x is not a string");
}
// Does this narrow?
type _Test_x_narrows_to_string = Expect<Equal<typeof x, string>>;
discard<string>(x);
}
/*
{
// --- CASE 5: Factory WITHOUT explicit return type annotation (inferred) --- //
// FAILS: Inferred return type + destructuring = no narrowing
const createUtils = () => {
const utils: Utils = {
die: (m) => {
throw new Error(m);
},
};
return utils;
};
const { die } = createUtils();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof die>, never>>;
const x = getX();
if (typeof x !== "string") {
die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
{
// --- CASE 6: Factory using `satisfies` instead of type annotation --- //
// FAILS: `satisfies` doesn't help with narrowing
const createUtils = () => {
return {
die: (m: string) => {
throw new Error(m);
},
} satisfies Utils;
};
const { die } = createUtils();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof die>, never>>;
const x = getX();
if (typeof x !== "string") {
die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
{
// --- CASE 7: Inline object with `satisfies` (no factory) --- //
// FAILS: `satisfies` doesn't trigger narrowing like `: Type` annotation does
const utils = {
die: (m) => {
throw new Error(m);
},
} satisfies Utils;
const x = getX();
if (typeof x !== "string") {
utils.die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
*/
/*
{
// --- CASE 8: Using a class (explicit never) --- //
// FAILS: Classes don't trigger narrowing even with explicit `: never`
class UtilsClass implements Utils {
die(m: string): never {
throw new Error(m);
}
}
const utils = new UtilsClass();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof utils.die>, never>>;
const x = getX();
if (typeof x !== "string") {
utils.die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
{
// --- CASE 8b: Class WITHOUT explicit never annotation --- //
// FAILS: Class methods infer `void` instead of `never` (doesn't even compile)
class UtilsClass implements Utils {
die(m: string) { // TypeScript infers `void`, not `never`
throw new Error(m);
}
}
const utils = new UtilsClass();
// This test would fail: ReturnType is `void`, not `never`
}
{
// --- CASE 9: Factory that returns class instance --- //
// FAILS: Factory + class = no narrowing
class UtilsClass implements Utils {
die(m: string): never {
throw new Error(m);
}
}
const createUtils = () => new UtilsClass();
const utils = createUtils();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof utils.die>, never>>;
const x = getX();
if (typeof x !== "string") {
utils.die("x is not a string");
}
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
{
// --- CASE 10: Assertion function via property access --- //
// FAILS: "Assertions require every name in the call target to be declared
// with an explicit type annotation"
type AssertString = (x: unknown, msg: string) => asserts x is string;
interface AssertUtils {
assertString: AssertString;
}
const createUtils = (): AssertUtils => ({
assertString: (x, msg): asserts x is string => {
if (typeof x !== "string") throw new Error(msg);
},
});
const utils = createUtils();
const x = getX();
utils.assertString(x, "x is not a string"); // Error: assertion requires explicit type
type _Test_x_narrows_to_string_FAILS = Expect<Equal<typeof x, string | number>>;
}
*/
{
// --- CASE 11: Explicit type on const (not destructured) --- //
type AssertString = (x: unknown, msg: string) => asserts x is string;
const createAssert = (): AssertString => {
return (x, msg): asserts x is string => {
if (typeof x !== "string") throw new Error(msg);
};
};
const assertString: AssertString = createAssert();
const x = getRandomValue();
assertString(x, "x is not a string");
// ✓ WORKS! Assertion function with explicit type annotation on const
type _Test_x_narrows_to_string = Expect<Equal<typeof x, string>>;
discard<string>(x);
}
{
// --- CASE 12: Apply same pattern to never-returning die --- //
const createDie = (): Die => {
return (m) => {
throw new Error(m);
};
};
const die: Die = createDie();
type _Test_die_returns_never = Expect<Equal<ReturnType<typeof die>, never>>;
const x = getRandomValue();
if (typeof x !== "string") {
die("x is not a string");
}
// ✓ WORKS! Explicit type on const holding function from factory
type _Test_x_narrows_to_string = Expect<Equal<typeof x, string>>;
discard<string>(x);
}
{
// --- CASE 13: Object factory with explicit Utils type annotation --- //
const createUtils = (): Utils => ({
die: (m) => {
throw new Error(m);
},
});
const utils: ReturnType<typeof createUtils> = createUtils(); // Explicit type annotation
const x = getRandomValue();
if (typeof x !== "string") {
utils.die("x is not a string");
}
// Does explicit type on object help?
type _Test_x_narrows_to_string = Expect<Equal<typeof x, string>>;
discard<string>(x);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment