Problem
A member-carrying callable type has two equivalent spellings: a declaration body, and an intersection of a call signature with the rest. typia treats the intersection spelling inconsistently — it accepts six of these shapes and rejects six structurally analogous ones as a nonsensible intersection.
Where it rejects, the intersection also disagrees with the declaration spelling of the same type, which is accepted. So the same type is supported or refused depending on how it was written, and which way it goes is not predictable from any stated rule.
Evidence
Measured on master at ab978b0a27, transforming one single-shape project per spelling and recording the exit code and diagnostic counts. Each row is the same type written two ways.
| Shape |
Interface spelling |
Intersection spelling |
Method |
code=0 |
code=0 |
OptionalMethod |
code=0 |
code=0 |
OptionalMethodProperty |
code=0 |
code=0 |
OptionalMember |
code=0 |
code=0 |
SymbolMember |
code=0 |
code=0 |
OptionalSymbolMember |
code=0 |
code=0 |
Overload |
code=0 |
code=3, 2 nonsensible |
ConstructOverload |
code=0 |
code=3, 2 nonsensible |
CallAndConstruct |
code=0 |
code=3, 2 nonsensible |
MethodProperty |
code=0 |
code=3, 2 nonsensible |
RequiredMember |
code=0 |
code=3, 2 nonsensible |
IndexSignature |
code=0 |
code=3, 2 nonsensible |
Two diagnostics per rejected row because the fixture exercises both the direct and the factory form.
The split is not random, which is what makes it worth stating precisely rather than as "sometimes":
Method is accepted while MethodProperty is rejected — cancel(): void and cancel: () => void are the same type.
OptionalMember is accepted while RequiredMember is rejected — the only difference is ?.
- Every shape whose intersection combines two call signatures (
Overload, ConstructOverload, CallAndConstruct) is rejected.
Owner: packages/typia/native/core/factories/internal/metadata/iterate_metadata_intersection.go. The accept path is if onlyObjects && len(tagObjects) == 0 { return escape(false) }; the reject path is reached when a surviving member is not a single-bucket object after phantom brands are dropped.
Consequence surface
- Any
typia.* operation on a type spelled as an intersection of a call signature with further members, in direct and factory forms.
- A refactor that TypeScript considers a no-op —
cancel(): void to cancel: () => void, or adding ? to a member — flips a project between compiling and failing.
- Downstream generators that emit intersection spellings cannot predict which of their outputs typia will accept.
Invariant
Equivalent spellings of one type receive one answer. Whether that answer is "supported, treated structurally" or "rejected with a diagnostic" is a product decision, but it must not depend on the spelling, and it must not differ between two shapes that TypeScript considers the same type.
Note the interface spelling is already settled: #2250 established that a member-carrying callable stays structural, and all twelve shapes above are accepted that way. Aligning the intersection spelling with it would make the contract uniform without changing any currently accepted behaviour.
Acceptance and verification
- For each of the twelve shapes, the interface, named-type-literal, inline-literal, and intersection spellings report the same exit code and the same diagnostics.
cancel(): void and cancel: () => void agree; the optional and required forms of one member agree.
- A genuinely nonsensible intersection — two unrelated atomics, say — is still rejected, so the alignment does not blanket-accept.
- The regression fails when only the alignment is reverted.
Coordination
Found while verifying #2238, which owns the declaration spellings and is not the cause: its own regression measured this while establishing that interface, named type literal, and inline literal agree. Its author had assumed from source reading that typia rejects every such intersection and pinned that as a hand-over point; execution shows it rejects only half of them, so the assumption was wrong in both directions and that expectation is being removed there rather than restated.
Problem
A member-carrying callable type has two equivalent spellings: a declaration body, and an intersection of a call signature with the rest. typia treats the intersection spelling inconsistently — it accepts six of these shapes and rejects six structurally analogous ones as a nonsensible intersection.
Where it rejects, the intersection also disagrees with the declaration spelling of the same type, which is accepted. So the same type is supported or refused depending on how it was written, and which way it goes is not predictable from any stated rule.
Evidence
Measured on
masteratab978b0a27, transforming one single-shape project per spelling and recording the exit code and diagnostic counts. Each row is the same type written two ways.Methodcode=0code=0OptionalMethodcode=0code=0OptionalMethodPropertycode=0code=0OptionalMembercode=0code=0SymbolMembercode=0code=0OptionalSymbolMembercode=0code=0Overloadcode=0code=3, 2 nonsensibleConstructOverloadcode=0code=3, 2 nonsensibleCallAndConstructcode=0code=3, 2 nonsensibleMethodPropertycode=0code=3, 2 nonsensibleRequiredMembercode=0code=3, 2 nonsensibleIndexSignaturecode=0code=3, 2 nonsensibleTwo diagnostics per rejected row because the fixture exercises both the direct and the factory form.
The split is not random, which is what makes it worth stating precisely rather than as "sometimes":
Methodis accepted whileMethodPropertyis rejected —cancel(): voidandcancel: () => voidare the same type.OptionalMemberis accepted whileRequiredMemberis rejected — the only difference is?.Overload,ConstructOverload,CallAndConstruct) is rejected.Owner:
packages/typia/native/core/factories/internal/metadata/iterate_metadata_intersection.go. The accept path isif onlyObjects && len(tagObjects) == 0 { return escape(false) }; the reject path is reached when a surviving member is not a single-bucket object after phantom brands are dropped.Consequence surface
typia.*operation on a type spelled as an intersection of a call signature with further members, in direct and factory forms.cancel(): voidtocancel: () => void, or adding?to a member — flips a project between compiling and failing.Invariant
Equivalent spellings of one type receive one answer. Whether that answer is "supported, treated structurally" or "rejected with a diagnostic" is a product decision, but it must not depend on the spelling, and it must not differ between two shapes that TypeScript considers the same type.
Note the interface spelling is already settled: #2250 established that a member-carrying callable stays structural, and all twelve shapes above are accepted that way. Aligning the intersection spelling with it would make the contract uniform without changing any currently accepted behaviour.
Acceptance and verification
cancel(): voidandcancel: () => voidagree; the optional and required forms of one member agree.Coordination
Found while verifying #2238, which owns the declaration spellings and is not the cause: its own regression measured this while establishing that interface, named type literal, and inline literal agree. Its author had assumed from source reading that typia rejects every such intersection and pinned that as a hand-over point; execution shows it rejects only half of them, so the assumption was wrong in both directions and that expectation is being removed there rather than restated.