Skip to content

Commit 838f1fb

Browse files
committed
Add typed shape syntax for hash payload contracts
1 parent 9695841 commit 838f1fb

7 files changed

Lines changed: 273 additions & 3 deletions

File tree

ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Goal: make types expressive enough for real workflows while keeping runtime chec
170170

171171
- [x] Add parametric container types: `array<T>`, `hash<K, V>`.
172172
- [x] Add union types beyond nil: `A | B`.
173-
- [ ] Add typed object/hash shape syntax for common payload contracts.
173+
- [x] Add typed object/hash shape syntax for common payload contracts.
174174
- [ ] Add typed block signatures where appropriate.
175175
- [ ] Define type display formatting for readable runtime errors.
176176

@@ -190,8 +190,8 @@ Goal: make types expressive enough for real workflows while keeping runtime chec
190190

191191
### Testing and Docs
192192

193-
- [ ] Add parser tests for all new type syntax forms.
194-
- [ ] Add runtime tests for nested composite type checks.
193+
- [x] Add parser tests for all new type syntax forms.
194+
- [x] Add runtime tests for nested composite type checks.
195195
- [ ] Add regression tests for existing `any` and nullable behavior.
196196
- [ ] Expand `docs/typing.md` with migration examples.
197197

docs/typing.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ Parametric containers:
1818
- `hash<K, V>` checks each key against `K` and each value against `V`
1919
- Example: `array<int>`, `array<int | string>`, `hash<string, int>`
2020

21+
Shape types for object/hash payload contracts:
22+
23+
- `{ id: string, score: int }` requires exactly those keys
24+
- Field values are recursively type-checked
25+
- Extra keys and missing keys fail validation
26+
2127
Nullable: append `?` to allow `nil` (e.g., `string?`, `time?`, `int?`).
2228

2329
Unions: join allowed types with `|` (e.g., `int | string`, `int | nil`).
@@ -39,6 +45,10 @@ def normalize_id(id: int | string) -> string
3945
id.string
4046
end
4147
48+
def apply_bonus(payload: { id: string, points: int }) -> { id: string, points: int }
49+
{ id: payload[:id], points: payload[:points] + 5 }
50+
end
51+
4252
def nil_result() -> nil
4353
nil
4454
end
@@ -70,4 +80,5 @@ Duration methods like `ago`/`after` return `Time`. Typed signatures use `time` o
7080

7181
- Types are nominal by kind.
7282
- Hash keys are runtime strings, so `hash<K, V>` key checks run against string keys.
83+
- Shape types are strict: keys must match exactly.
7384
- Type names are case-insensitive (`Int` == `int`).

vibes/ast.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const (
6161
TypeArray
6262
TypeHash
6363
TypeFunction
64+
TypeShape
6465
TypeUnion
6566
TypeUnknown
6667
)
@@ -70,6 +71,7 @@ type TypeExpr struct {
7071
Kind TypeKind
7172
Nullable bool
7273
TypeArgs []*TypeExpr
74+
Shape map[string]*TypeExpr
7375
Union []*TypeExpr
7476
position Position
7577
}

vibes/execution.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3360,6 +3360,33 @@ func valueMatchesType(val Value, ty *TypeExpr) (bool, error) {
33603360
return true, nil
33613361
case TypeFunction:
33623362
return val.Kind() == KindFunction, nil
3363+
case TypeShape:
3364+
if val.Kind() != KindHash && val.Kind() != KindObject {
3365+
return false, nil
3366+
}
3367+
entries := val.Hash()
3368+
if len(ty.Shape) == 0 {
3369+
return len(entries) == 0, nil
3370+
}
3371+
for field, fieldType := range ty.Shape {
3372+
fieldVal, ok := entries[field]
3373+
if !ok {
3374+
return false, nil
3375+
}
3376+
matches, err := valueMatchesType(fieldVal, fieldType)
3377+
if err != nil {
3378+
return false, err
3379+
}
3380+
if !matches {
3381+
return false, nil
3382+
}
3383+
}
3384+
for field := range entries {
3385+
if _, ok := ty.Shape[field]; !ok {
3386+
return false, nil
3387+
}
3388+
}
3389+
return true, nil
33633390
case TypeUnion:
33643391
for _, option := range ty.Union {
33653392
matches, err := valueMatchesType(val, option)
@@ -3420,6 +3447,8 @@ func formatTypeExpr(ty *TypeExpr) string {
34203447
name = "hash"
34213448
case TypeFunction:
34223449
name = "function"
3450+
case TypeShape:
3451+
name = formatShapeType(ty)
34233452
default:
34243453
name = ty.Name
34253454
}
@@ -3439,6 +3468,22 @@ func formatTypeExpr(ty *TypeExpr) string {
34393468
return name
34403469
}
34413470

3471+
func formatShapeType(ty *TypeExpr) string {
3472+
if ty == nil || len(ty.Shape) == 0 {
3473+
return "{}"
3474+
}
3475+
fields := make([]string, 0, len(ty.Shape))
3476+
for field := range ty.Shape {
3477+
fields = append(fields, field)
3478+
}
3479+
sort.Strings(fields)
3480+
parts := make([]string, len(fields))
3481+
for i, field := range fields {
3482+
parts[i] = fmt.Sprintf("%s: %s", field, formatTypeExpr(ty.Shape[field]))
3483+
}
3484+
return "{ " + strings.Join(parts, ", ") + " }"
3485+
}
3486+
34423487
// Function looks up a compiled function by name.
34433488
func (s *Script) Function(name string) (*ScriptFunction, bool) {
34443489
fn, ok := s.functions[name]

vibes/parser.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,9 @@ func (p *parser) parseTypeExpr() *TypeExpr {
10251025
}
10261026

10271027
func (p *parser) parseTypeAtom() *TypeExpr {
1028+
if p.curToken.Type == tokenLBrace {
1029+
return p.parseTypeShape()
1030+
}
10281031
if p.curToken.Type != tokenIdent && p.curToken.Type != tokenNil {
10291032
p.errorExpected(p.curToken, "type name")
10301033
return nil
@@ -1063,3 +1066,68 @@ func (p *parser) parseTypeAtom() *TypeExpr {
10631066

10641067
return ty
10651068
}
1069+
1070+
func (p *parser) parseTypeShape() *TypeExpr {
1071+
pos := p.curToken.Pos
1072+
fields := make(map[string]*TypeExpr)
1073+
1074+
if p.peekToken.Type == tokenRBrace {
1075+
p.nextToken()
1076+
return &TypeExpr{
1077+
Kind: TypeShape,
1078+
Shape: fields,
1079+
position: pos,
1080+
}
1081+
}
1082+
1083+
p.nextToken()
1084+
for {
1085+
key, ok := p.parseTypeShapeFieldName()
1086+
if !ok {
1087+
return nil
1088+
}
1089+
if p.peekToken.Type != tokenColon {
1090+
p.errorExpected(p.peekToken, ":")
1091+
return nil
1092+
}
1093+
p.nextToken()
1094+
p.nextToken()
1095+
fieldType := p.parseTypeExpr()
1096+
if fieldType == nil {
1097+
return nil
1098+
}
1099+
if _, exists := fields[key]; exists {
1100+
p.addParseError(p.curToken.Pos, fmt.Sprintf("duplicate shape field %s", key))
1101+
return nil
1102+
}
1103+
fields[key] = fieldType
1104+
1105+
if p.peekToken.Type == tokenComma {
1106+
p.nextToken()
1107+
p.nextToken()
1108+
continue
1109+
}
1110+
if p.peekToken.Type != tokenRBrace {
1111+
p.errorExpected(p.peekToken, "}")
1112+
return nil
1113+
}
1114+
p.nextToken()
1115+
break
1116+
}
1117+
1118+
return &TypeExpr{
1119+
Kind: TypeShape,
1120+
Shape: fields,
1121+
position: pos,
1122+
}
1123+
}
1124+
1125+
func (p *parser) parseTypeShapeFieldName() (string, bool) {
1126+
switch p.curToken.Type {
1127+
case tokenIdent, tokenString, tokenSymbol:
1128+
return p.curToken.Literal, true
1129+
default:
1130+
p.errorExpected(p.curToken, "shape field name")
1131+
return "", false
1132+
}
1133+
}

vibes/parser_types_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package vibes
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestParserTypeSyntaxCompositeForms(t *testing.T) {
9+
source := `def run(
10+
rows: array<int | string>,
11+
payload: { id: string, stats: { wins: int } }
12+
) -> hash<string, { score: int | nil }>
13+
payload
14+
end`
15+
16+
p := newParser(source)
17+
program, errs := p.ParseProgram()
18+
if len(errs) > 0 {
19+
t.Fatalf("expected no parse errors, got %v", errs)
20+
}
21+
if len(program.Statements) != 1 {
22+
t.Fatalf("expected 1 statement, got %d", len(program.Statements))
23+
}
24+
fn, ok := program.Statements[0].(*FunctionStmt)
25+
if !ok {
26+
t.Fatalf("expected function statement, got %T", program.Statements[0])
27+
}
28+
if len(fn.Params) != 2 {
29+
t.Fatalf("expected 2 params, got %d", len(fn.Params))
30+
}
31+
32+
rowsType := fn.Params[0].Type
33+
if rowsType == nil || rowsType.Kind != TypeArray || len(rowsType.TypeArgs) != 1 {
34+
t.Fatalf("expected array<T> param, got %#v", rowsType)
35+
}
36+
elemType := rowsType.TypeArgs[0]
37+
if elemType.Kind != TypeUnion || len(elemType.Union) != 2 {
38+
t.Fatalf("expected union element type, got %#v", elemType)
39+
}
40+
41+
payloadType := fn.Params[1].Type
42+
if payloadType == nil || payloadType.Kind != TypeShape {
43+
t.Fatalf("expected shape payload type, got %#v", payloadType)
44+
}
45+
if _, ok := payloadType.Shape["id"]; !ok {
46+
t.Fatalf("expected shape field id")
47+
}
48+
statsType, ok := payloadType.Shape["stats"]
49+
if !ok || statsType.Kind != TypeShape {
50+
t.Fatalf("expected nested stats shape, got %#v", statsType)
51+
}
52+
winsType, ok := statsType.Shape["wins"]
53+
if !ok || winsType.Kind != TypeInt {
54+
t.Fatalf("expected stats.wins int type, got %#v", winsType)
55+
}
56+
57+
if fn.ReturnTy == nil || fn.ReturnTy.Kind != TypeHash || len(fn.ReturnTy.TypeArgs) != 2 {
58+
t.Fatalf("expected hash<K,V> return type, got %#v", fn.ReturnTy)
59+
}
60+
valueType := fn.ReturnTy.TypeArgs[1]
61+
if valueType.Kind != TypeShape {
62+
t.Fatalf("expected shaped hash value type, got %#v", valueType)
63+
}
64+
scoreType, ok := valueType.Shape["score"]
65+
if !ok || scoreType.Kind != TypeUnion || len(scoreType.Union) != 2 {
66+
t.Fatalf("expected score union type, got %#v", scoreType)
67+
}
68+
}
69+
70+
func TestParserTypeShapeRejectsDuplicateFields(t *testing.T) {
71+
source := `def run(payload: { id: string, id: int })
72+
payload
73+
end`
74+
75+
p := newParser(source)
76+
_, errs := p.ParseProgram()
77+
if len(errs) == 0 {
78+
t.Fatalf("expected parse errors")
79+
}
80+
if got := errs[0].Error(); !strings.Contains(got, "duplicate shape field id") {
81+
t.Fatalf("unexpected parse error: %s", got)
82+
}
83+
}

vibes/runtime_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,14 @@ func TestTypedFunctions(t *testing.T) {
11831183
def mixed_items(values: array<int | string>) -> array<int | string>
11841184
values
11851185
end
1186+
1187+
def player_payload(payload: { id: string, score: int, active: bool? }) -> { id: string, score: int, active: bool? }
1188+
payload
1189+
end
1190+
1191+
def shaped_rows(rows: array<{ id: string, stats: { wins: int } }>) -> array<{ id: string, stats: { wins: int } }>
1192+
rows
1193+
end
11861194
`)
11871195

11881196
if fn, ok := script.Function("bad_return"); !ok || fn.ReturnTy == nil {
@@ -1224,6 +1232,23 @@ func TestTypedFunctions(t *testing.T) {
12241232
if got := callFunc(t, script, "mixed_items", []Value{NewArray([]Value{NewInt(1), NewString("two"), NewInt(3)})}); got.Kind() != KindArray {
12251233
t.Fatalf("mixed_items expected array result, got %v", got.Kind())
12261234
}
1235+
if got := callFunc(t, script, "player_payload", []Value{NewHash(map[string]Value{
1236+
"id": NewString("p-1"),
1237+
"score": NewInt(42),
1238+
"active": NewNil(),
1239+
})}); got.Kind() != KindHash {
1240+
t.Fatalf("player_payload expected hash result, got %v", got.Kind())
1241+
}
1242+
if got := callFunc(t, script, "shaped_rows", []Value{NewArray([]Value{
1243+
NewHash(map[string]Value{
1244+
"id": NewString("p-1"),
1245+
"stats": NewHash(map[string]Value{
1246+
"wins": NewInt(7),
1247+
}),
1248+
}),
1249+
})}); got.Kind() != KindArray {
1250+
t.Fatalf("shaped_rows expected array result, got %v", got.Kind())
1251+
}
12271252
if got := callFunc(t, script, "nil_result", nil); !got.Equal(NewNil()) {
12281253
t.Fatalf("nil_result mismatch: %v", got)
12291254
}
@@ -1288,6 +1313,42 @@ func TestTypedFunctions(t *testing.T) {
12881313
if err == nil || !strings.Contains(err.Error(), "expected array<int | string>") {
12891314
t.Fatalf("expected typed union array arg error, got %v", err)
12901315
}
1316+
1317+
_, err = script.Call(context.Background(), "player_payload", []Value{
1318+
NewHash(map[string]Value{
1319+
"id": NewString("p-1"),
1320+
"score": NewInt(42),
1321+
"role": NewString("captain"),
1322+
}),
1323+
}, CallOptions{})
1324+
if err == nil || !strings.Contains(err.Error(), "expected { active: bool?, id: string, score: int }") {
1325+
t.Fatalf("expected shape extra-field error, got %v", err)
1326+
}
1327+
1328+
_, err = script.Call(context.Background(), "player_payload", []Value{
1329+
NewHash(map[string]Value{
1330+
"id": NewString("p-1"),
1331+
"score": NewString("wrong"),
1332+
"active": NewBool(true),
1333+
}),
1334+
}, CallOptions{})
1335+
if err == nil || !strings.Contains(err.Error(), "expected { active: bool?, id: string, score: int }") {
1336+
t.Fatalf("expected shape field-type error, got %v", err)
1337+
}
1338+
1339+
_, err = script.Call(context.Background(), "shaped_rows", []Value{
1340+
NewArray([]Value{
1341+
NewHash(map[string]Value{
1342+
"id": NewString("p-1"),
1343+
"stats": NewHash(map[string]Value{
1344+
"wins": NewString("bad"),
1345+
}),
1346+
}),
1347+
}),
1348+
}, CallOptions{})
1349+
if err == nil || !strings.Contains(err.Error(), "expected array<{ id: string, stats: { wins: int } }>") {
1350+
t.Fatalf("expected nested shape error, got %v", err)
1351+
}
12911352
}
12921353

12931354
func TestArrayAndHashHelpers(t *testing.T) {

0 commit comments

Comments
 (0)