Skip to content

getTargetOfType panics on non-generic types via the api server #3072

@souporserious

Description

@souporserious

Hello 👋 I came across this bug while building a type resolver tool with Claude. Please note, the rest of this message is copy and pasted, but I double checked and ran the following myself to verify it is in-fact a bug.


Calling getTargetOfType on a type whose TypeFlags do not match any of the cases in (*Type).Target() — such as TypeFlagsIntersection or TypeFlagsUnion — causes a recovered panic that is returned as an internal JSON-RPC error (code: -32603).

The panic message is panic: Unhandled case in Type.Target from internal/checker/types.go:614.

The server survives (the panic is recovered by AsyncConn.handleRequest), but the call returns an error rather than null, which is the semantically correct response for a type that has no target.


Environment

  • Binary: @typescript/native-preview npm package
  • Version: 7.0.0-dev.20260311.1 (tsgo --version)
  • Commit: a4ea5bdc0b3d4128a90105928049f10292133729
  • OS: Linux x64
  • Protocol: tsgo --api --async (JSON-RPC 2.0 over stdio)

Reproduction

1. Setup

npm install -g @typescript/native-preview

Create a minimal project:

mkdir /tmp/repro && cat > /tmp/repro/index.ts << 'EOF'
export type Branded = string & { readonly __brand: "Branded" };
export type HttpMethod = "GET" | "POST" | "PUT";
EOF

cat > /tmp/repro/tsconfig.json << 'EOF'
{
  "compilerOptions": { "strict": true, "noEmit": true },
  "include": ["index.ts"]
}
EOF

2. Run the reproduction script

Save this as repro.py and run python3 repro.py:

import subprocess, json, sys

def msg(obj):
    body = json.dumps(obj).encode()
    return f"Content-Length: {len(body)}\r\n\r\n".encode() + body

def recv(proc):
    header = b""
    while not header.endswith(b"\r\n\r\n"):
        ch = proc.stdout.read(1)
        if not ch:
            return None
        header += ch
    length = int(header.split(b"Content-Length: ")[1].split(b"\r\n")[0])
    return json.loads(proc.stdout.read(length))

proc = subprocess.Popen(
    ["tsgo", "--api", "--async", "--cwd", "/tmp/repro"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)

seq = 0
def call(method, params={}):
    global seq
    seq += 1
    proc.stdin.write(msg({"jsonrpc": "2.0", "id": seq, "method": method, "params": params}))
    proc.stdin.flush()
    return recv(proc)

call("initialize")
snap = call("updateSnapshot", {"openProject": "/tmp/repro/tsconfig.json"})["result"]
snapshot  = snap["snapshot"]
project   = snap["projects"][0]["id"]

source = open("/tmp/repro/index.ts").read()

for type_name, expected_flag in [("Branded", "TypeFlagsIntersection (1<<28)"),
                                  ("HttpMethod", "TypeFlagsUnion (1<<27)")]:
    pos = source.index(type_name)
    sym = call("getSymbolAtPosition",
               {"snapshot": snapshot, "project": project,
                "file": "/tmp/repro/index.ts", "position": pos})["result"]

    t = call("getDeclaredTypeOfSymbol",
             {"snapshot": snapshot, "project": project, "symbol": sym["id"]})["result"]

    result = call("getTargetOfType",
                  {"snapshot": snapshot, "project": project, "type": t["id"]})

    if "error" in result:
        print(f"FAIL  {type_name} ({expected_flag}): {result['error']['message'].splitlines()[0]}")
    else:
        print(f"PASS  {type_name}: {result['result']}")

proc.terminate()

3. Expected output

PASS  Branded (TypeFlagsIntersection): null
PASS  HttpMethod (TypeFlagsUnion): null

4. Actual output

FAIL  Branded (TypeFlagsIntersection (1<<28)): panic: Unhandled case in Type.Target
FAIL  HttpMethod (TypeFlagsUnion (1<<27)): panic: Unhandled case in Type.Target

Full error response (JSON-RPC)

{
  "jsonrpc": "2.0",
  "id": 6,
  "error": {
    "code": -32603,
    "message": "panic: Unhandled case in Type.Target\ngoroutine 228 [running]:\nruntime/debug.Stack()\n\truntime/debug/stack.go:26 +0x5e\ngithub.com/microsoft/typescript-go/internal/api.(*AsyncConn).handleRequest.func1()\n\tgithub.com/microsoft/typescript-go/internal/api/conn_async.go:96 +0x58\npanic({0xca1740?, 0x109fb90?})\n\truntime/panic.go:860 +0x13a\ngithub.com/microsoft/typescript-go/internal/checker.(*Type).Target(0x18f0a8679b00?)\n\tgithub.com/microsoft/typescript-go/internal/checker/types.go:614 +0xd0\ngithub.com/microsoft/typescript-go/internal/api.(*Session).resolveTypeProperty(0x18f0a7e59740?, 0x18f0a8bb2d80, 0x10994b0)\n\tgithub.com/microsoft/typescript-go/internal/api/session.go:1115 +0x56\ngithub.com/microsoft/typescript-go/internal/api.(*Session).handleGetTargetOfType(...)\n\tgithub.com/microsoft/typescript-go/internal/api/session.go:1148\n..."
  }
}

Root cause

(*Type).Target() in internal/checker/types.go only handles four flag combinations:

func (t *Type) Target() *Type {
    switch {
    case t.flags&TypeFlagsObject != 0:
        return t.AsObjectType().target
    case t.flags&TypeFlagsTypeParameter != 0:
        return t.AsTypeParameter().target
    case t.flags&TypeFlagsIndex != 0:
        return t.AsIndexType().target
    case t.flags&TypeFlagsStringMapping != 0:
        return t.AsStringMappingType().target
    case t.flags&TypeFlagsObject != 0 && t.objectFlags&ObjectFlagsMapped != 0:
        return t.AsMappedType().target
    }
    panic("Unhandled case in Type.Target")  // ← reached for Intersection, Union, etc.
}

TypeFlagsIntersection (1 << 28), TypeFlagsUnion (1 << 27), TypeFlagsSubstitution (1 << 24), TypeFlagsConditional (1 << 26), TypeFlagsIndexedAccess (1 << 25), TypeFlagsTemplateLiteral (1 << 22), and all primitive/literal types are not covered. None of these have a target field in the type-checker sense that getTargetOfType is designed to query (that concept applies to instantiated generic types).

The panic is recovered by (*AsyncConn).handleRequest and serialised as a code: -32603 error, so the server continues running — but callers receive an error instead of the correct null (no target).


Suggested fix

Option A — guard in handleGetTargetOfType (minimal, API layer):

In internal/api/session.go, add a flag check before calling Target():

func (s *Session) handleGetTargetOfType(_ context.Context, params *GetTypePropertyParams) (*TypeResponse, error) {
    sd, err := s.getSnapshotData(params.Snapshot)
    if err != nil {
        return nil, err
    }
    t, err := sd.resolveTypeHandle(params.Type)
    if err != nil {
        return nil, err
    }
    // Target() only applies to instantiated Object, TypeParameter, Index, and StringMapping types.
    // For all other flags (Union, Intersection, Conditional, etc.) there is no target — return nil.
    const targetableFlags = checker.TypeFlagsObject |
        checker.TypeFlagsTypeParameter |
        checker.TypeFlagsIndex |
        checker.TypeFlagsStringMapping
    if t.Flags()&targetableFlags == 0 {
        return nil, nil
    }
    result := t.Target()
    if result == nil {
        return nil, nil
    }
    return sd.registerType(result), nil
}

Option B — return nil in (*Type).Target() instead of panicking (checker layer):

func (t *Type) Target() *Type {
    switch {
    case t.flags&TypeFlagsObject != 0:
        return t.AsObjectType().target
    case t.flags&TypeFlagsTypeParameter != 0:
        return t.AsTypeParameter().target
    case t.flags&TypeFlagsIndex != 0:
        return t.AsIndexType().target
    case t.flags&TypeFlagsStringMapping != 0:
        return t.AsStringMappingType().target
    case t.flags&TypeFlagsObject != 0 && t.objectFlags&ObjectFlagsMapped != 0:
        return t.AsMappedType().target
    }
    return nil  // type has no target (Union, Intersection, primitives, etc.)
}

Option B is safer and consistent with how (*Type).Types() and similar accessors handle inapplicable types.


Additional notes

  • This was discovered while building a Go client for the tsgo --api --async server that calls getTargetOfType to inspect type alias definitions.
  • The duplicate case t.flags&TypeFlagsObject != 0 (for the MappedType branch) will never be reached since the first Object case fires first — that's a separate minor issue.
  • All other type sub-property methods tested (getTypesOfType, getTypeParametersOfType, getPropertiesOfType, getSignaturesOfType) handle non-applicable types correctly by returning null or [].

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions