A data format that feels like home.
Clean to write, with real types built in: variants, dates and times, exact numbers. And your data can grow without breaking the code that reads it.
Draft 0.1. Still changing, so don't use it in production yet.
This is haus.
An order, written the way you'd describe it. If you can read JSON, you can read this.
// Comments are welcome, keys don't need quotes.
{
order: 1042,
placed: Date("2026-11-08"),
total: 30.30,
status: Shipped {
tracking: "DE-4711",
at: Instant("2026-11-09T09:12:00Z"),
},
gift: True,
note: None,
}
-
Shippedis a variant. The status names its case and carries the data that goes with it. -
Dateis a real date, checked by every parser. No guessing what a string means. -
30.30stays 30.30. Your code gets exactly what you wrote. -
Nonemeans no value. Like True, it's a plain tag, so there's no null.
What makes it a home.
What every project ends up inventing for itself, built into the format once.
-
Pleasant to write
No quotes around keys, comments where you need them, trailing commas, and multi-line text that keeps its shape. Easy to write by hand, easy to read in a diff.
note.haus { // keys without quotes, comments, trailing commas note: """ Leave it at the door, the bell is broken. """, } -
Variants
A value says which case it is and carries the data for that case. In your code it becomes a type the compiler checks. Close a variant when the list is final, or leave it open so you can add cases later.
order.schema.haus // closed: exactly these cases, nothing else type Status = closed < | Pending | Shipped { tracking: String } | Cancelled > // open: new cases can be added later type Event = < Login { user: String } | Logout > -
Grows without breaking
Readers skip fields they don't know and keep cases they don't know yet, so you can add to your data at any time. When a type must be exact, mark it closed.
logs.haus // a reader that doesn't know Deploy yet // keeps it as an unknown value Deploy { version: "1.4.2" }, -
Types built from types
Build a type from other types instead of copying them: ...UserEvent brings in every case of UserEvent. Each module defines its own events, and the app's event type combines them, still fully checked.
events.schema.haus // the user module's events type UserEvent = < | UserCreated { id: Int } | UserRenamed { id: Int, to: String } > // all user events plus Heartbeat, nothing else type AppEvent = closed < ...UserEvent | Heartbeat > -
Dates and times, built in
Date, Instant, Duration and time zones work in every parser, in one strict ISO 8601 format. No more guessing what a date string means.
order.haus { placed: Date("2026-11-07"), at: Instant("2026-11-07T09:12:00Z"), within: Duration("PT48H"), } -
Make it yours
Everything beyond time comes as an extension: a schema package you install like any other dependency. Codecs map its types onto your own. We maintain official extensions for common needs, and anyone can publish their own.
order.schema.haus import std/time (Date) // built in import @haus/geo (LatLng) // an extension import ./common (Address) // your own -
Checked as you type
A schema is optional: every haus file can be read without one. Add a schema next to your data, and your editor checks the data as you write it while your build turns the schema into real types.
order.haus // schema: ./order.schema.haus { order: 1042, placed: Date("2026-11-08") } -
Streams built in
A file can hold many values in a row. Append a line, tail -f it, or handle each entry as soon as it arrives.
logs.haus Log { level: Info, message: "started" }, Request { path: "/orders/7", status: 200 },
At home in your code.
Any haus file parses on its own, just like JSON. When you want types, put a
.schema.haus file next to your data: your build reads it and gives you
real types. Today in TypeScript,
Rust and Roc, with
more languages to come.
Comment {
author: "ada",
at: Instant("2026-11-01T09:00:00Z"),
text: "Should haus have null?",
likes: 0,
replies: [
Comment {
author: "bob",
at: Instant("2026-11-02T09:00:00Z"),
text: "JSON has it, people expect it.",
likes: 2,
replies: [],
},
],
}
A comment thread, where each reply is a comment too.
import std/time (Instant)
type Comment = closed < Comment {
author: String,
at: Instant,
text: String,
likes: Int,
replies: Array Comment,
} >
type Root = Comment
Its schema. Comment refers to itself, so threads can nest.
import * as Haus from "@haus-fmt/effect"
import { Effect } from "effect"
import thread from "./thread.haus" // checked against its schema
import { Comment } from "./thread.schema.haus"
thread.author // String → string
thread.at // Instant → DateTime.Utc
thread.likes // Int → bigint, no size limit
thread.replies // Array Comment → Comment[]
const program = Effect.gen(function* () {
const liked = { ...thread, likes: thread.likes + 1n }
return yield* Haus.encode(Comment, { style: "compact" })(liked)
})
Using the Effect binding. Zod, Valibot and ArkType work too, through Standard Schema.
mod thread {
haus::include_schema!("thread"); // from thread.schema.haus
}
use thread::Comment;
let thread: Comment = haus::from_str(include_str!("thread.haus"))?;
thread.author // String → String
thread.likes // Int → haus::Int, no size limit
thread.replies // Array Comment → Vec<Comment>
let likes = thread.likes.to_i128().unwrap_or_default() + 1;
let liked = Comment { likes: likes.into(), ..thread };
println!("{}", haus::to_string(&liked));
haus-build generates the types at compile time.
import haus.Decode
import haus.Encode
import Thread
path : Path
path = "thread.haus"
thread = Decode.one(path.read_utf8!()?, Thread.decode_root)?
# author : Str String
# likes : I128 Int
# replies : List(Comment) Array Comment
liked = match thread {
Comment(c) => Comment({ ..c, likes: c.likes + 1 })
}
Stdout.line!(Encode.to_str(Thread.encode_comment(liked)))?
haus-gen generates the types. The Roc parser is still in progress.
- Typed data. Import a data file like a module, already typed by its schema.
- Clear errors. When data doesn't match, the error tells you where.
- Your own types. Codecs map schema types onto yours, for example a Date onto your date library.
The same data in JSON.
JSON can hold all of it, but only by convention: a "type" field for the
status, plain strings for the date and the amount. Every team has to agree on those rules and write them
down. In haus, they're part of the format.
{
order: 1042,
placed: Date("2026-11-08"),
total: 30.30,
status: Shipped {
tracking: "DE-4711",
at: Instant("2026-11-09T09:12:00Z"),
},
gift: True,
note: None,
}
{
"order": 1042,
"placed": "2026-11-08",
"total": "30.30",
"status": {
"type": "shipped",
"tracking": "DE-4711",
"at": "2026-11-09T09:12:00Z"
},
"gift": true,
"note": null
}