Model your data with types
In Tutorial 2 the shortener echoed strings around. Now we give it a real data model. Bynk gives you three ways to shape data, and choosing the right one is most of the work of modelling a domain well:
- Records group related fields together.
- Sum types (and their no-payload form, enums) express “one of several alternatives”.
- Opaque types give a value a distinct identity so it cannot be confused with another value of the same underlying shape.
We will model the shortener’s requests, responses, and errors, and meet match.
Keep editing shortener.bynk; we compile it at the end.
Records group fields
Section titled “Records group fields”A record groups fields into a single value. The shortener needs a request body and two response shapes:
type CreateLinkRequest = { target: String,}
type CreatedView = { code: String, target: String,}
type ResolveView = { target: String, hits: Int,}(The String fields become precise refined types in
Tutorial 4; for now plain strings keep us moving.) You
construct a record by naming it and giving every field a value:
Created(CreatedView { code: "abc123", target: body.target })Records are immutable. To produce a changed copy, use the spread form
{ ...r, … }, which copies every field and overrides the ones you name (we will
use it for agent state in Tutorial 5). A record compiles
to a TypeScript interface with readonly fields:
export interface ResolveView { readonly target: string; readonly hits: number;}A sum type for errors
Section titled “A sum type for errors”Creating or resolving a link can go wrong in a few distinct ways. That is exactly what a sum type expresses — a value that is one of several named variants. When none of the variants carries a payload, the shorthand is an enum:
type LinkError = enum { AlreadyExists, NotFound, Invalid,}LinkError is one of three variants. You construct one by naming it —
AlreadyExists. It compiles to a discriminated union plus a constructor
namespace:
export type LinkError = { readonly tag: "AlreadyExists" } | { readonly tag: "NotFound" } | { readonly tag: "Invalid" };
export const LinkError = { AlreadyExists: { tag: "AlreadyExists" } as LinkError, NotFound: { tag: "NotFound" } as LinkError, Invalid: { tag: "Invalid" } as LinkError,};(A variant can also carry data — Shipped(tracking: String) — but our errors are
plain tags, so an enum is the right tool. See the
type reference for payload-carrying variants.)
Read a sum type with match
Section titled “Read a sum type with match”To read a sum type, you match on it. match forces you to handle every
variant, so adding a case later makes the compiler revisit every place that
inspects the type:
fn describe(error: LinkError) -> String { match error { AlreadyExists => "code already in use" NotFound => "no such code" Invalid => "invalid code" }}If you forget a variant, the program does not compile — there is no way to fall
through a case by accident. match compiles to a switch on the tag:
export function describe(error: LinkError): string { switch (error.tag) { case "AlreadyExists": { return "code already in use"; } case "NotFound": { return "no such code"; } case "Invalid": { return "invalid code"; } } throw new Error("non-exhaustive match");}Opaque types, in one minute
Section titled “Opaque types, in one minute”The third tool is the opaque type: a value backed by some base type but with its own identity, so the compiler refuses to mix it up with another value of the same underlying shape.
type LinkId = opaque String -- a String, but not interchangeable with oneLinkId compiles to a branded type — string & { readonly __brand: "LinkId" }
— so a plain String cannot stand in for it. Opacity is the right tool when you
want identity. For the shortener’s short codes, though, we want more than
identity — we want to guarantee the string is actually a valid code. That is a
job for refined types, and it is exactly where we go next.
Compile what we have
Section titled “Compile what we have”Here is shortener.bynk so far — the data model wired into the API:
context shortener
type LinkError = enum { AlreadyExists, NotFound, Invalid,}
type CreateLinkRequest = { target: String,}
type CreatedView = { code: String, target: String,}
type ResolveView = { target: String, hits: Int,}
fn describe(error: LinkError) -> String { match error { AlreadyExists => "code already in use" NotFound => "no such code" Invalid => "invalid code" }}
service api from http { on POST("/links") by Visitor (body: CreateLinkRequest) -> Effect[HttpResult[CreatedView]] { Created(CreatedView { code: "abc123", target: body.target }) }
on GET("/links/:code") by Visitor (code: String) -> Effect[HttpResult[ResolveView]] { NotFound }}bynkc compile . --output out --target workersWhat you have done
Section titled “What you have done”You modelled the shortener’s data with the core type kinds — CreateLinkRequest,
CreatedView, and ResolveView records, and a LinkError enum — and consumed
the sum with an exhaustive match. This is the everyday vocabulary of Bynk data
modelling.
Right now a code is any old String. Next we sharpen that: how do we stop an
invalid short code — too short, wrong shape — from being constructed at all?
➡️ Tutorial 4: Make illegal states unrepresentable
For the reasoning behind opacity, sums, and immutable records, see The type-system philosophy. For exact rules, see the type system reference.