Skip to content

fix(types): enhance type system with mapped types and reverse-mapped …#2

Open
shayanhabibi wants to merge 1 commit into
verify-cloudflare-sdk-pipelinefrom
prevent-silent-drops-in-export-map
Open

fix(types): enhance type system with mapped types and reverse-mapped …#2
shayanhabibi wants to merge 1 commit into
verify-cloudflare-sdk-pipelinefrom
prevent-silent-drops-in-export-map

Conversation

@shayanhabibi
Copy link
Copy Markdown
Collaborator

Fix silent export drops

  • Introduced MappedType and ReverseMappedType to extend type system flexibility.
  • Updated TypeFlagObject and STypeUnionBuilder to account for commutative and ordered type assemblies.
  • Refined handling of index signatures and type mapping in dispatch logic.
  • Improved export resolution by grouping and sorting dependencies for better consistency.

…types. Fix silent export drops

- Introduced `MappedType` and `ReverseMappedType` to extend type system flexibility.
- Updated `TypeFlagObject` and `STypeUnionBuilder` to account for commutative and ordered type assemblies.
- Refined handling of index signatures and type mapping in dispatch logic.
- Improved export resolution by grouping and sorting dependencies for better consistency.
@shayanhabibi
Copy link
Copy Markdown
Collaborator Author

shayanhabibi commented May 12, 2026

Fixes

  • ExportMap is a map with a list value. Prevents dropping of types on construction.
  • Commutative unions and intersection types are sorted on construction in the encoder, and on each pass of the decoder dedupe - allows existing dedupe pass to handle appropriately.
  • MappedType handling no longer drops key type/value type.

To do

  • Further typar preservation in other contexts.

  • Further work on improving mapping: The plan for handling mapping on the generator side, and what information is required to make this happen is explained below.

Value-level modeling of nameType mapped types

F# can't express the type-level identity of f(K), but the runtime is JS — and the encoder already commits to a "Fable substrate" vocabulary (proptypekey, keyof, typekeyof, proptypelock). Adding a value-level key constructor is the same kind of move: cover the runtime semantics with a piece of static API, accept that the F# type checker can't prove the relationship, and lean on [<Emit>] to make the JS faithful.

The shape of the model

For each kind of nameType, a different value-level companion:

1. Pure key transformationas \get${Capitalize<K & string>}``

type Getters<'T> =
    [<Emit("`get${$1.charAt(0).toUpperCase() + $1.slice(1)}`")>]
    static member keyOf (k: keyof<'T>) : string = jsNative

    [<Emit("$0[$1]()")>]
    member _.invoke (mappedKey: string) : proptypekey<'T, _> = jsNative

Total. No option. The transformation is total over keyof T. Access:

let g: Getters<MyType> = ...
let name = g.invoke (Getters.keyOf "name")

2. never-filteringas K extends U ? never : K

type Without<'T, 'U> =
    [<Emit("$1 in $0 ? $1 : undefined")>]
    static member tryKeyOf (k: keyof<'T>) : string option = jsNative

    [<Emit("$0[$1]")>]
    member _.tryGet (k: string) : proptypekey<'T, _> option = jsNative

Partial. option carries the "this key was filtered" branch — which is better than TS's compile-time-only cence is real.

3. Combined transformation + filteringas K extends Foo ? \on${K}` : never`

Same shape as (2) but the JS emission for tryKeyOf includes both the predicate and the transformation. The Ff<'T> -> string option. The user doesn't need to know which clause produced None`.

Why this is more than a workaround

  • Runtime evidence is the source of truth in Fable land. F# nominal types are erased at the JS layer. Whatnd that's exactly what the key constructor encodes. The static F# type doesn't have to equal the TS type forthe binding to be correct; it just has to support the operations the JS object supports. The substrate already accepts this trade for proptypekey/keyof.
  • The option for filtering carries information TS doesn't. A TS user calling (without as any)['a'] gets The F# user calling without.tryGet "a" gets None and is forced by the type system to handle it. A genuineusability win over the source language.
  • Composability with existing intrinsics. proptypekey<'T, _> already exists. The key constructor produceifts it to a value type. The two compose naturally — no new substrate primitive needed for the common case, just a new emission pattern for the mapped-type alias itself.

Where the encoder complexity actually lives

The non-trivial work is classifying nameType ASTs and emitting equivalent JS:

Pattern JS emission Encoder effort
as \${literal}${K}`` template string with $1 interpolation low — walk template parts
as \${Capitalize<K & string>}`` etc. template + charAt/slice/toUpperCase medium — table of intrinsi
as K extends U ? never : K predicate function inline medium — emit U-membership test
as Conditional<K, ...> with non-never arms requires evaluating the conditional at runtime high — lik
Arbitrary user-defined type-level fn n/a bail to opaque

The first three tiers cover essentially all nameType use in real-world packages — the lib ones (Capitalize, Uncapitalize, Uppercase, Lowercase) and the canonical filtering idiom. The fourth tier is rare and the bail path catches it cleanly.

Implementation sketch:

| TypeNode.MappedType mappedTypeNode ->
    match mappedTypeNode.nameType with
    | Some nameType ->
        match KeyRemap.classify ctx nameType with
        | KeyRemap.TemplateLiteral parts ->
            emitTemplateKeyConstructor ctx xanTag mappedTypeNode parts
        | KeyRemap.NeverFilter predicate ->
            emitFilteringKeyConstructor ctx xanTag mappedTypeNode predicate
        | KeyRemap.TransformAndFilter (parts, predicate) ->
            emitCombinedKeyConstructor ctx xanTag mappedTypeNode parts predicate
        | KeyRemap.Unsupported ->
            // Terminate body as Any; surrounding alias becomes `type Foo<'T> = obj`.
            xanTag.TypeSignal
            |> Signal.fulfillWith (fun () -> TypeSignal.ofKey TypeKindPrimitive.Any.TypeKey)
            setTypeKeyFromNode mappedTypeNode
    | None ->
        routeViaChecker mappedTypeNode

KeyRemap.classify is the new code surface — a small AST classifier over nameType. Each branch emits a different shape of TypeLiteral with the appropriate static members.

Catches worth flagging

  • The mapped type is no longer a plain TypeLiteral; it's a TypeLiteral plus static members. Implications for heritage — interface X extends Getters<T> becomes weirder. But not worse than today: today extends Getters<T> resolves to a lying IndexSignature F# can't honor anyway. The heritage rule (drop heritage for index-shaped targets, inline the me
  • The substrate intrinsics need extension for the string-not-keyof<T> case. When the constraint is plain string

Recommendation: two phases

  1. Phase 1, lands with the encoder typar fix: node-level nameType detection, terminate body as Any (do not forward to symbol declaration — for anonymous mapped types the symbol IS the enclosing alias, so a forward re-enters this branch). No key-constructor emission yet. Stops the silent lie and lets the existing substitution layer decide whether to map specific utility types to obj. Small change, immediate correctness win, no new substrate surface.

  2. Phase 2, separate PR: the KeyRemap.classify machinery and the three emission patterns. The value-level modeling. Earns its keep on packages that use Getters-style or Omit-via-never idioms — real but not universal. Worth doing once Phase 1 stabilizes the error picture enough to measure Phase 2's actual coverage.

Splitting it this way keeps the encoder fix focused on "stop producing wrong output" first, then adds "produce useful output for cases we previously gave up on" as a follow-on. Each PR has a measurable error delta and a reviewable scope.

@shayanhabibi
Copy link
Copy Markdown
Collaborator Author

shayanhabibi commented May 12, 2026

Value-level modeling of nameType mapped types

F# can't express the type-level identity of f(K), but the runtime is JS — and the encoder already commits to a "Fable substrate" vocabulary (proptypekey, keyof, typekeyof, proptypelock). Adding a value-level key constructor is the same kind of move: cover the runtime semantics with a piece of static API, accept that the F# type checker can't prove the relationship, and lean on [<Emit>] to make the JS faithful.

Where the encoder complexity actually lives

The non-trivial work is classifying nameType ASTs and emitting equivalent JS:

Pattern JS emission Encoder effort
as \${literal}${K} template string with `$1` interpolation low — walk template parts `as \`${Capitalize<K & string>} etc. template + charAt/slice/toUpperCase medium — table of intrinsi
as K extends U ? never : K predicate function inline medium — emit U-membership test
as Conditional<K, ...> with non-never arms requires evaluating the conditional at runtime high — lik
Arbitrary user-defined type-level fn n/a bail to opaque
The first three tiers cover essentially all nameType use in real-world packages — the lib ones (Capitalize, Uncapitalize, Uppercase, Lowercase) and the canonical filtering idiom. The fourth tier is rare and the bail path catches it cleanly.

I think this is actually all baloney. There is absolutely no difficulty in having the encoder provide the required information. The information is fine to present as an index signature. The type slots for the index signature will be appropriately traversed and given. It will be up to the generator to either generate workarounds like those listed in the above plan, or to just default to obj.

I would prefer that, over adding another type that needs to be pattern matched against.

@shayanhabibi
Copy link
Copy Markdown
Collaborator Author

Reframed: encoder transmits, downstream decides

I overstated the case earlier. I was conflating two things:

  1. "The encoder's IndexSignature shape is structurally wrong for remapped types"
  2. "F# can't render a remapped type with full fidelity"

(2) is true but it's downstream policy, not encoder concern. (1) is what I claimed but it's actually false — the IndexSignature is just a carrier. Its Parameter Type field can hold any TsType, including a TemplateLiteralType, a ConditionalType, or any other type-level expression. The bug isn't that the encoder uses IndexSignature; the bug is that the encoder discards nameType and substitutes string as the parameter type. That discard is the loss, not the shape choice.

The encoder already has the vocabulary

The encoded model can transmit remapped keys faithfully:

  • TypeNode.TemplateLiteralType (line ~357 in TypeNode.fs) — template literal types are first-class.
  • TypeNode.ConditionalType — conditional types likewise.
  • TypeNode.TypeReference to a typar — typar references go through the existing pipeline.

So the actual fix at TypeNode.MappedType is:

| TypeNode.MappedType mappedTypeNode ->
    let keyType =
        match mappedTypeNode.nameType with
        | Some nameTypeNode -> getTypeSignalFromNode nameTypeNode   // f(K), faithfully
        | None              -> getTypeSignalFromNode mappedTypeNode.typeParameter.constraint  // K
    let valueType = getTypeSignalFromNode mappedTypeNode.``type``
    let isReadonly = mappedTypeNode.readonlyToken |> Option.isSome
    // Build IndexSignature with the real types. Encoder transmits, doesn't decide.
    { ... Parameters = [| { Name = "key"; Type = keyType; ... } |]
          Type = valueType
          IsReadOnly = isReadonly } |> SMemberBuilder.IndexSignature ...
    setTypeKeyFromNode mappedTypeNode

The encoded form now carries:

  • Record<K, V> → Parameter Type = K (typar), Type = V (typar). The Record-collapse bug evaporates bedistinct content.
  • **Getters<T> with as \get${Capitalize}`** → Parameter Type = the template literal type expression, Type = () => T[K]`.
  • Without<T, U> with as K extends U ? never : K → Parameter Type = the conditional type expression, Ty

Same IndexSignature shape, full type information preserved. The encoder stops lying.

Where the policy actually lives

Once the encoder transmits faithfully, the generator chooses how to render. Three options, all generator-side:

  1. obj substitution. Default for cases the generator can't or won't try to handle. Same machinery as `Liost: erases information at render time. Safe baseline.
  2. SRTP-like overloads / member-constraint patterns. When the template-literal transformation matches a recognized shape (Capitalize<K & string>, etc.), generate an F# binding that uses SRTP or generic member constraints to express the key
    shape. Cost: brittle to surface variations; F# SRTP has its own ergonomic limits. Real option for the highest-
  3. Attributes preserving the original TS shape. Emit the binding with [<TSMapped(...)>]-style attributes carrying the structured remap info for downstream tooling (IDE hovers, documentation generators, even later codegen). Cost: nothing
    functional, but pays back if the attribute schema is consumed.

None of these require the encoder to know anything about F#'s representability.

Reconciling with the earlier two-phase split

The Phase 1 / Phase 2 split that put key-constructor emission in the encoder was wrong. The encoder doesn't emncoded model. The key-constructor pattern, if it's done at all, is a generator concern that reads the encodedTemplateLiteralType in the Parameter Type slot and decides to render the SRTP/key-constructor pattern instead of obj.

Corrected split:

Encoder fix (single, principled): TypeNode.MappedType reads nameType and routes it as the Parameter Type of the IndexSignature; reads templateType and routes it as the value Type; reads readonlyToken and routes it as IsReadOnly. No more hardcoded String.TypeKey / Any.TypeKey. No bail to opaque. No node-level escape hatch. The encoder stops making rendering decisions.

Generator policy (optional, layered): the existing substitution layer (LibEsDefaults) sees the encoded TemplateLiteralType in a Parameter slot and decides whether to:

  • Pass through as-is (rendering then naturally produces something like obj because F# has no template-literal-type)
  • Match the pattern against a known set and emit SRTP / attributes
  • Add a substitution to map specific utility types to obj for ergonomics

The Record-collapse bug is solved by the encoder fix alone — the same patch covers nameType and template/constraint typars. They're not two fixes; they're one fix that says "encoder transmits faithfully."

Where the earlier framing went wrong

I said the IndexSignature "can't carry f(K) keys, never-filtering, or co-varying V." That's wrong. It can carry any TsType in the Parameter Type slot. What it can't carry is the runtime invariant that the JS object's actual keys correspond to f(K) — but that's a runtime concern, and at the encoded-model layer there's no runtime, just the type graph. The encoder describes the type graph; whatever uses the encoded model decides what to do with the description.

I conflated "structurally faithful encoded model" with "F#-renderable as a literal index signature." The first is the encoder's job and is achievable. The second is a separate question the generator answers, with the three options above — and the encoder isn't responsible for picking among them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant