-
Notifications
You must be signed in to change notification settings - Fork 875
Description
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-previewnpm 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-previewCreate 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"]
}
EOF2. 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 --asyncserver that callsgetTargetOfTypeto inspect type alias definitions. - The duplicate
case t.flags&TypeFlagsObject != 0(for theMappedTypebranch) will never be reached since the firstObjectcase 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 returningnullor[].