Records

This file covers record creation and flat property access on fully-known records. For row-polymorphic access through lambda parameters, see Rows.fs.

record creation from literals

Inferred
Source
{
    age = 22
    name = "John"
    address = "123 Main St"
}
Inferred
{ age: Number; name: String; address: String }

The most direct way to produce a record: build it inline from literals. The result type is a structural record — one field per definition, each carrying the type of its value. Notably, no record name was declared anywhere; the type is inferred purely from the shape. This is the headline feature demonstrated at its simplest.

F# test body
X.MkRecord [
    X.Field "age" (X.Lit 22)
    X.Field "name" (X.Lit "John")
    X.Field "address" (X.Lit "123 Main St")
]
|> solve [] None
|> shouldSolveType (
        Mono (TDef.NamedRecordWith (NameHint.Given "Person") [
            "age", BuiltinTypes.number
            "name", BuiltinTypes.string
            "address", BuiltinTypes.string
        ]))

property access on environment-provided record

Inferred
Env
add   : Number -> Number -> Number
order : { quantity: Number }
Source
add 10 order.quantity
Inferred
Number

Property access on a record bound in the environment.

F# test body
X.App (X.App (X.Var "add") (X.Lit 10)) (X.PropAcc (X.Var "order") "quantity")
|> solve
    [
        yield! envWithArithmetic
        yield "order", Mono (TDef.NamedRecordWith (NameHint.Given "Order") [
            "quantity", BuiltinTypes.number
        ])
    ]
    None
|> shouldSolveType (Mono BuiltinTypes.number)

let-bound record keeps its fields

Inferred
Source
let myRecord =
    {
        age = 22
        name = "John"
    }
myRecord
Inferred
{ age: Number; name: String }

Round-trips a record through a let binding and returns it unchanged. The test confirms that let doesn't lose information — the outer expression's inferred type is exactly the record's type, fields and all. A type checker that "forgot" fields at a binding boundary would be visibly broken here.

F# test body
X.Let (X.Ident "myRecord") (
    X.MkRecord [
        X.Field "age" (X.Lit 22)
        X.Field "name" (X.Lit "John")
    ]
) (X.Var "myRecord")
|> solve [] None
|> shouldSolveType (
        Mono (TDef.NamedRecordWith (NameHint.Given "Person") [
            "age", BuiltinTypes.number
            "name", BuiltinTypes.string
        ]))

property access on a let-bound record

Inferred
Source
let myRecord = { age = 22; name = "John" } in
myRecord.name
Inferred
String

Combines let with .-field access — both covered elsewhere in isolation — and checks the two interact correctly. After binding the record, reading name should yield that field's type. This is the everyday shape of record usage, so it better just work.

F# test body
X.Let (X.Ident "myRecord") (
    X.MkRecord [
        X.Field "age" (X.Lit 22)
        X.Field "name" (X.Lit "John")
    ]
) (
    X.PropAcc (X.Var "myRecord") "name"
)
|> solve [] None
|> shouldSolveType (Mono BuiltinTypes.string)

property access on a let-bound record (2)

Inferred
Source
let myRecord = { age = 22; name = "John" } in
myRecord.age
Inferred
Number

Same expression, different field. Together with the previous test this confirms the inferencer returns the right field type per access, not just the first one it finds. It's easy to write a naïve implementation that always gets one access correct and quietly confuses the other.

F# test body
X.Let (X.Ident "myRecord") (
    X.MkRecord [
        X.Field "age" (X.Lit 22)
        X.Field "name" (X.Lit "John")
    ]
) (
    X.PropAcc (X.Var "myRecord") "age"
)
|> solve [] None
|> shouldSolveType (Mono BuiltinTypes.number)

accessing a non-existing field fails

Expected error
Source
let myRecord = { age = 22; name = "John" } in
myRecord.xxxxxxxx
Error
Member 'xxxxxxxx' is missing in record type { age: Number; name: String }

Asking for a field that isn't there must be a static error. The record's full shape is known at this point, so a missing-member problem can (and should) be caught before the program runs. Dynamic languages surface this as a runtime crash — TypeFighter refuses to typecheck the program at all.

F# test body
X.Let (X.Ident "myRecord") (
    X.MkRecord [
        X.Field "age" (X.Lit 22)
        X.Field "name" (X.Lit "John")
    ]
) (
    X.PropAcc (X.Var "myRecord") "xxxxxxxx"
)
|> solve [] None
|> shouldFail

comparing two differently-typed fields fails

Expected error
Env
EQUALS : forall a. a -> a -> Bool
Source
let r = { IntField = 3; BooleanField = true } in
EQUALS r.BooleanField r.IntField
Error
Can't unify Bool and Number

Both arguments to EQUALS must be the same type, but the accessed fields disagree.

F# test body
let env =
    [ "EQUALS", TDef.Generalize (%1 ^-> %1 ^-> BuiltinTypes.boolean) ]

X.Let
    (X.Ident "r")
    (X.MkRecord [
        X.Field "IntField" (X.Lit 3)
        X.Field "BooleanField" (X.Lit true)
    ])
    (X.App
        (X.App (X.Var("EQUALS")) (X.PropAcc (X.Var "r") "BooleanField"))
        (X.PropAcc (X.Var "r") "IntField"))
|> solve env None
|> shouldFail

using multiple fields in a boolean expression

Inferred
Env
AND    : Bool -> Bool -> Bool
EQUALS : forall a. a -> a -> Bool
Source
let r = { IntField = 3; BooleanField = true } in
AND (EQUALS r.IntField 3) (EQUALS r.BooleanField true)
Inferred
Bool

Accessing multiple fields, each compared against a matching literal.

F# test body
let env =
    [
        "AND", Mono(BuiltinTypes.boolean ^-> BuiltinTypes.boolean ^-> BuiltinTypes.boolean)
        "EQUALS", TDef.Generalize (%1 ^-> %1 ^-> BuiltinTypes.boolean)
    ]

let left =
    X.App
        (X.App (X.Var("EQUALS")) (X.PropAcc (X.Var "r") "IntField"))
        (X.Lit 3)
let right =
    X.App
        (X.App (X.Var("EQUALS")) (X.PropAcc (X.Var "r") "BooleanField"))
        (X.Lit true)
X.Let
    (X.Ident "r")
    (X.MkRecord [
        X.Field "IntField" (X.Lit 3)
        X.Field "BooleanField" (X.Lit true)
    ])
    (X.App
        (X.App (X.Var "AND") left)
        right)
|> solve env None
|> shouldSolveType (Mono(BuiltinTypes.boolean))