Created
February 7, 2026 02:29
-
-
Save trvswgnr/561778f812d37d52adafe84c011c3cd0 to your computer and use it in GitHub Desktop.
typescript `never` on objects weird behavior
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // --- 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