#Typing `typeof`: `Typeof<T>`

96 messages · Page 1 of 1 (latest)

sullen osprey
#

I made a small type alias for this question:

given a value of type T, what strings could JS typeof value return at runtime?

Not proposing it for TS itself. I know the team does not take new utility types. I just want feedback on whether this seems useful.

export type Typeof<T> = void extends T // explicitly treat void as a top type to account for lambda returns
    ? 'object' | 'function' | 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined'
    : TypeofType<T, undefined, 'undefined'> | TypeofType<T, null, 'object'> | TypeofImpl<T & {}>;
type TypeofImpl<T> = T extends Function // all callable objects are functions
    ? 'function'
    : TypeofType<T, string, 'string'>
    | TypeofType<T, boolean, 'boolean'>
    | TypeofType<T, number, 'number'>
    | TypeofType<T, symbol, 'symbol'>
    | TypeofType<T, bigint, 'bigint'>
    | TypeofType<T, Function, 'function'>
    | TypeofType<T, object, 'object'>;
type TypeofType<A, B, S> = A extends B ? S : B extends A ? S : never;

Examples:

Typeof<string>          // "string"
Typeof<null>            // "object"
Typeof<() => void>      // "function"
Typeof<string | number> // "string" | "number"
Typeof<{ a: 1 } | null> // "object"

What it helps with: plain typeof value in generic code usually widens to the full JS typeof union, even when T rules out most cases. Typeof<T> keeps that result correlated with T.

const typeOf = <T>(value: T) => typeof value as Typeof<T>;

That makes things like these possible:

const isTypeof = <T>(value: T, expected: Typeof<T>) => typeof value === expected;

declare const x: string | number | null;

isTypeof(x, 'string');   // ok
isTypeof(x, 'number');   // ok
isTypeof(x, 'object');   // ok
isTypeof(x, 'function'); // error

and:

type EventPayload = string | bigint | { id: string } | (() => void);
type DispatchKey = Typeof<EventPayload>;
// "string" | "bigint" | "object" | "function"
#

Typeof<T> is most useful when an API is genuinely keyed by JS typeof buckets.

Typed wrapper around runtime typeof:

function typeOf<T>(value: T): Typeof<T> {
    return typeof value as Typeof<T>;
}

const a = typeOf('x');                    // "string"
const b = typeOf(Math.random() ? 1 : null); // "number" | "object"

Handler tables / generic dispatch:

type Matcher<R> = {
    string?: (v: string) => R;
    number?: (v: number) => R;
    boolean?: (v: boolean) => R;
    bigint?: (v: bigint) => R;
    symbol?: (v: symbol) => R;
    function?: (v: Function) => R;
    object?: (v: object | null) => R;
    undefined?: (v: undefined) => R;
};

function match<T, R>(value: T, handlers: Pick<Matcher<R>, Typeof<T>>): R | undefined {
    const k = typeOf(value);
    return handlers[k]?.(value as never);
}

match('x' as 1 | 'x' | null, {
    string: s => s.length,
    object: _ => 0,
    number: n => n + 1,
});

Without Typeof<T>, k is usually just the full "string" | "number" | ... union, so handlers[k] becomes much less useful.

#

Another example: deriving only the dispatch keys that can actually occur.

type Payload = string | bigint | { id: string } | (() => void);
type Keys = Typeof<Payload>;
// "string" | "bigint" | "object" | "function"

Caveat: this does not currently integrate with TS control-flow analysis.

function handle<T>(value: T) {
    switch (typeOf(value)) {
        case 'string':
            value; // not narrowed to string
            break;
    }
}

That is not because Typeof<T> is unsafe. TS also does not seem to narrow from a non-local typeof result in general.

Another caveat: this intentionally follows JS typeof, not finer TS distinctions. So:

  • null maps to "object" because JS does.
  • callable objects map to "function".
  • void is special-cased to the full union because values from void-typed positions can still be anything at runtime:
function call(callback: () => void) {
    const x = callback(); // x: void
    const k: Typeof<typeof x> = typeof x;
    console.log(k);
}

call(() => 45);  // "number"
call(() => 'x'); // "string"

So the intended reading is:

Typeof<T> = possible observable JS typeof results for a value typed as T

not:

the most precise TS-internal classification of T

That distinction is the whole point of it.

Interested in thoughts on:

  • whether this seems practically useful
  • whether the void choice seems right
  • whether the symmetric TypeofType<A, B, S> test has bad edge cases I missed (see gist).
candid flare
#

The usefulness seems pretty limited. The match example seems like the only useful one (I would argue the marcher shouldn't be partial and should enforce all branches), and even then it could be written as a good old switch.

sullen vale
#

typeof<{ /* anything here other than a call/construct signature */ }> would be able to yield everything except undefined

lapis agate
#

I'd be interested in hearing more. I know that for example { prop: 123 } can be inhabited by string & { prop: 123 } but the only ways I'm aware to create that at runtime involve primitives converted to objects, e.g. Object.apply("foo", { prop: 123 }) which at runtime implies it's a String, and thusly typeof Object.apply("foo", { prop: 123 }) === "object"

#

You're absolutely correct if you mean typeof<{}> specifically should be 'object' | 'function' | 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' due to being everything but undefined (and null but null is an object)

#

something like typeof<{ x?: number }> is a bit trickier. For similar reasons you can also needs to be 'object' | 'function' | 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' due to being able to be inhabited by all of those but it also depends on transitive assignment.

For examples:

const a: {} = "foo"; // Trivial upcast
const b: { x?: number } = a; // Indirect assignment, you may want to consider indirect assignments invalid though.

The reason why this is tricky has mostly to do the fact that you can abuse any, void, never, sometimes object/{}, and other strange edge cases like overloads or other tricks to break apparent types to cause broken transitive assignments. I personally consider const a = ...; const b: T = a; the "true" form of assignability for a type.

sullen vale
#

{ toString: () => string } would also need to be everything, trivially

#

you can also do prototype pollution on any of them

#

(excluding undefined from the above 2 statements)

lapis agate
#

True, I forgot about prototype pollution

#

since it's not normally relevant to types, as the types don't always reflect everything

#

Though I think it's a generally safe assumption that either: prototype pollution doesn't happen or that if it does they at least type it.

#

In that case you do need to change the implementation some, but to less of an extreme

sullen vale
#

i think some specific cases would still be relevant, ie { length: number } or { [Symbol.iterator]: () => /* ... */ }

lapis agate
#

absolutely

sullen vale
#

and then you kinda do need to draw the line somewhere

lapis agate
#

but { length: number } extends string would be truthy

#

which they actually do account for

sullen vale
#

ah, i didn't notice that. i'd initially read on mobile, mb

lapis agate
#
type A = Typeof<{ length: number }>
//   ^ "string" | "object" | "function"
#

yeah

sullen vale
#
type X = Typeof<{ (): void; name: unknown; }>
//   ^?: "object"
```![thonk](https://cdn.discordapp.com/emojis/961423622147309618.webp?size=128 "thonk")
lapis agate
#

Yeah they misunderstood how Function works

#

Function -> (...args: never) => unknown fixes that

#

or your favorite function top type

sullen vale
#

would need an extra clause for construct signatures too then i suppose

lapis agate
#

ah true

sullen vale
#

i guess you could just merge that as extends (call top type) | (construct top type)

lapis agate
#

Yeah this probably works: ((...args: never) => unknown) | (abstract new (...args: never) => unknown)

sullen vale
#

yeah would need to change the Function at the bottom too

type X = Typeof<{ x: 0 }>
//   ^?: "object"
```this should be object | function
lapis agate
#

Though depending on your proclivities I think there's no one function top type because of... jank. So you can pop in ((...args: any) => unknown) | (abstract new (...args: any) => unknown) as well

#

might need even more function top types...

#

but I'm not going to think about that rn

sullen vale
#

i mean in this case as in a conditional, you could just slap in anys

lapis agate
#

I recall an extremely niche issue where it wasn't assignable to ...args: any

#

but that's probably patched by now

#

and so niche that no one cares

sullen vale
#

oh yeah, never isn't assignable to any

#

or the reverse? i don't remember that

lapis agate
#

other way around

sullen vale
#

ah yeah ok

lapis agate
#

though contravariance

#

but as a special case

#

(...args: never[]) => unknown is assignable to (...args: any[]) => unknown

#

it "shouldn't" be

#

you can tell it's a special case because (...args: never[]) => number is NOT assignable to (...args: any[]) => number

#

there's basically a isFunctionTopType check that looks for those cases and allows them to be mutually assigned and => number makes them no longer top types so they're no longer mutually assignable

#

I think it's a dumb special case

#

the check should've probably been based upon param assignability so that it isn't defeated by => number

candid flare
#

If I was to implement it, I would do something like:

type TypeMap = {
    object: object | null
    string: string
    number: number
    // ...
}

type Typeof<T> = {
    [K in keyof TypeMap]: T extends TypeMap[K] ? K : never
}[keyof TypeMap]

But with proper Function handling, and add void/unknown handling on top.

#

(Though mostly I question the utility of this)

lapis agate
#

yeah that's probably a terser way of doing it

#

you do need to get function fixed though, since { (): number; prop: number } would currently give "object" | "function" I presume

candid flare
#

Oh yeah.

#

I guess functions will have to be a level above just like void/unknown.

lapis agate
#

I suppose there's also Symbol.toPrimitive depending on how exactly you're using this but then I really question the utility

lapis agate
candid flare
#

Yeah the (un)soundness of this has already been discussed.

lapis agate
#

not actually pertinent right now but if you wanted to add arrays I will note that Typeof<{ prop: string }> would technically have to return "function" | "array" | "object"

sullen vale
sullen osprey
# lapis agate not actually pertinent _right now_ but if you wanted to add arrays I will note t...

Thanks for the feedback, i'll look into it. the Function type is definitely a pitfall.

Object.apply("foo", { prop: 123 }) which at runtime implies it's a String, and thusly typeof Object.apply("foo", { prop: 123 }) === "object"

Object.apply returns any, so soudness guarantees are already lost when using it, and Typeof<any> correctly expands to the whole union.

something like typeof<{ x?: number }> is a bit trickier. For similar reasons you can also needs to be 'object' | 'function' | 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' due to being able to be inhabited by all of those but it also depends on transitive assignment.
For examples:

 const a: {} = "foo"; // Trivial upcast
 const b: { x?: number } = a; // Indirect assignment, you may want to consider indirect assignments invalid though.

Regarding optional properties, Typeof<T> is meant to represent the union of values typeof can produce for any value in T's domain. If TypeScript does allow assigning an non-"object" to { x?: number }, then Typeof<{ x?: number }> should be treated as {} indeed.

not actually pertinent right now but if you wanted to add arrays I will note that Typeof<{ prop: string }> would technically have to return "function" | "array" | "object"

typeof cannot return "array". arrays are objects.

i'll rework the definitions and add test cases to the gist.

sullen osprey
# lapis agate Yeah they misunderstood how `Function` works

should Typeof<{ (): void; name: unknown }>; be "function" or "object" | "function"

Initially i though any object with a call signature must be only "function"

but this seems to type-check

const f = { ...Function() }
f() // Uncaught TypeError: f is not a function

Even though it fails at runtime. typeof f is expectedly 'object' here.

lapis agate
#

Yeah, though note that ALL object types can be inhabited by a function

#
const a = () => 123;
a.prop = 123;

const x: { prop: number } = a;
lapis agate
#
declare const g: {
    apply(this: Function, thisArg: any, argArray?: any): any;
    call(this: Function, thisArg: any, ...argArray: any[]): any;
    bind(this: Function, thisArg: any, ...argArray: any[]): any;
    toString(): string;
    prototype: any;
    length: number;
    arguments: any;
    caller: Function;
    name: string;
    [Symbol.hasInstance](value: any): boolean;
}
g(); // Ok

declare const h: {
    apply(this: Function, thisArg: any, argArray?: any): any;
    call(this: Function, thisArg: any, ...argArray: any[]): any;
    bind(this: Function, thisArg: any, ...argArray: any[]): any;
    toString(): string;
    prototype: any;
    length: number;
    arguments: any;
    caller: Function;
    // name: string; // Comment out ANY property, name is arbitrary
    [Symbol.hasInstance](value: any): boolean;
}
h(); // Error
sullen osprey
#

Another source of TypeScript unsoundness, add it to the list

lapis agate
#

well except toString apparently

lapis agate
sullen osprey
lapis agate
#

on 6.0.2 in the playground g works but h errors

sullen osprey
#

Sounds like a regression?

lapis agate
#

hmm

#

on the playground 5.9.3 also has the same behavior

#

how are you testing?

sullen osprey
#

f() and g() both error on 6.0.2 locally (from npm) as well

typeof.ts:31:1 - error TS2349: This expression is not callable.
  Type '{ apply(this: Function, thisArg: any, argArray?: any): any; call(this: Function, thisArg: any, ...argArray: any[]): any; bind(this: Function, thisArg: any, ...argArray: any[]): any; ... 6 more ...; [Symbol.hasInstance](value: any): boolean; }' has no call signatures.

31 g(); // Ok
   ~

typeof.ts:45:1 - error TS2349: This expression is not callable.
  Type '{ apply(this: Function, thisArg: any, argArray?: any): any; call(this: Function, thisArg: any, ...argArray: any[]): any; bind(this: Function, thisArg: any, ...argArray: any[]): any; ... 5 more ...; [Symbol.hasInstance](value: any): boolean; }' has no call signatures.

45 h(); // Error
   ~

my tsconfig

{
  "compilerOptions": {
    "lib": [
      "ESNext",
      "DOM"
    ],
    "module": "esnext",
    "noEmit": true,
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitAny": true,
    "verbatimModuleSyntax": true
  },
  "include": [
    "."
  ],
  "exclude": [
    "node_modules",
    "**/node_modules/*"
  ]
}
lapis agate
#

can you copy and paste what hovering const f = { ...Function } gives you?

#

I think it may be a lib file difference

#

the exact type of f, I suspect that your lib settings cause more or less to be declaration merged into Function

sullen osprey
#
const f: {
    apply(this: Function, thisArg: any, argArray?: any): any;
    call(this: Function, thisArg: any, ...argArray: any[]): any;
    bind(this: Function, thisArg: any, ...argArray: any[]): any;
    toString(): string;
    prototype: any;
    length: number;
    arguments: any;
    caller: Function;
    name: string;
    [Symbol.hasInstance](value: any): boolean;
    [Symbol.metadata]: DecoratorMetadataObject | null;
}

there's an additional [Symbol.metadata]: DecoratorMetadataObject | null; prop compared to your examples

#

if i include it in the declare const, the error disappears

#

it's from lib.esnext.decorators.d.ts

lapis agate
#

yeah, that'd be why

#

it needs to match Function 1:1 and in the playground apparently lib.esnext.decorators.d.ts isn't loaded

#

no clue as to why the playground wouldn't load that but you can hopefully now reproduce

#

This appears to be the reason why it's callable:

    /**
     * TS 1.0 spec: 4.12
     * If FuncExpr is of type Any, or of an object type that has no call or construct signatures
     * but is a subtype of the Function interface, the call is an untyped function call.
     */
    function isUntypedFunctionCall(funcType: Type, apparentFuncType: Type, numCallSignatures: number, numConstructSignatures: number): boolean {
        // We exclude union types because we may have a union of function types that happen to have no common signatures.
        return isTypeAny(funcType) || isTypeAny(apparentFuncType) && !!(funcType.flags & TypeFlags.TypeParameter) ||
            !numCallSignatures && !numConstructSignatures && !(apparentFuncType.flags & TypeFlags.Union) && !(getReducedType(apparentFuncType).flags & TypeFlags.Never) && isTypeAssignableTo(funcType, globalFunctionType);
    }
sullen osprey
#

yeah playground defaults to ES2017

lapis agate
#

I set to ESNext

#

it also defaults to ESNext these days anyways I think

#

but I don't know because I usually mess with the config settings already

#

oh you meant target perhaps?

sullen osprey
#

yes