Skip to main content

Model data with TypR types

This tutorial teaches you how to model real-world data using TypR's type system — records, unions, interfaces, and generics. You will build a small library for managing a contact list, step by step.

Duration: 15–20 minutes.

This is a tutorial. It teaches you by doing. For the full details, follow the links to the reference.

What you will learn

  • How to define record types for structured data
  • How to use tagged unions for variant data
  • How to write functions that operate on your types
  • How to use interfaces for polymorphic behavior
  • How to work with option types and error handling

Prerequisites

  • Basic knowledge of R and TypR (complete the Getting Started tutorial first)
  • The typr compiler installed

Step 1: Define your data types

Create a file called contacts.ty. Start with the core types:

# contacts.ty

type Email <- list {
address: char,
verified: bool
};

type Phone <- list {
number: char,
country_code: char
};

type Contact <- list {
name: char,
email: Email,
phone: Phone
};

These are record types — named structures with typed fields. Each field has a name and a type. The compiler will check that you use them correctly.

Step 2: Write constructors

Records in TypR are plain lists at runtime. Write constructor functions to create them:

let new_email <- fn(address: char): Email {
list(address = address, verified = false)
};

let new_phone <- fn(number: char, country: char): Phone {
list(number = number, country_code = country)
};

let new_contact <- fn(name: char, email: Email, phone: Phone): Contact {
list(name = name, email = email, phone = phone)
};

Notice that the return type Contact tells the compiler exactly what structure the function returns. You get type checking on the fields without extra work.

Step 3: Write functions on your types

Now write functions that operate on contacts:

let is_verified <- fn(c: Contact): bool {
c$email$verified
};

let display_name <- fn(c: Contact): char {
c$name
};

let full_info <- fn(c: Contact): char {
paste(c$name, "<", c$email$address, ">", c$phone$country_code, c$phone$number)
};

Because c is typed as Contact, the compiler knows that c$email is an Email and c$email$address is a char. Mistakes like c$email$phone fail at compile time.

Step 4: Use tagged unions for variant data

Not all data fits neatly into a single record. Use tagged unions for values that can be one of several things:

type VerificationStatus <- .Unverified | .Pending | .Verified(char);

type ContactV2 <- list {
name: char,
email: Email,
status: VerificationStatus
};

Each variant is prefixed with a dot (.). A tag can carry data — .Verified(char) holds the verification code.

Step 5: Pattern match on unions

Use match to handle each variant:

let get_status_label <- fn(c: ContactV2): char {
match c$status {
.Unverified => "Not verified",
.Pending => "Verification in progress",
.Verified(code) => paste("Verified with code:", code)
}
};

match is exhaustive — if you forget a variant, the compiler tells you. The payload is automatically destructured: code binds to the char inside .Verified.

Step 6: Use the Option pattern

A common pattern in typed languages is Option<T> — a value that might not exist. TypR does not have a built-in Option, but you can define one:

type Option`<T>` <- .Some(T) | .None;

let find_contact <- fn(contacts: [Any, ContactV2], name: char): Option<ContactV2> {
# Simplified: in real code you would iterate
.None
};

let greet <- fn(opt: Option<ContactV2>): char {
match opt {
.Some(c) => paste("Hello,", c$name),
.None => "Contact not found"
}
};

The generic parameter <T> makes Option reusable for any type. This is the idiomatic way to handle nullable values in TypR.

Step 7: Define an interface for polymorphism

Use an interface to define a capability that multiple types can share:

type Displayable <- interface {
display: (Self) -> char
};

Any type that has a display: (Self) -> char function automatically implements Displayable. No impl keyword needed.

Now write a function that works for any Displayable:

let print_item <- fn(item: Displayable): Empty {
print(display(item))
};

Step 8: Make your types implement the interface

Define display for each type:

let display <- fn(e: Email): char {
e$address
};

let display <- fn(p: Phone): char {
paste(p$country_code, p$number)
};

let display <- fn(c: ContactV2): char {
paste(c$name, "<", display(c$email), ">")
};

Now print_item works with emails, phones, and contacts — the compiler verifies that each type satisfies the Displayable interface.

Step 9: Build and test

Add a Test block to verify your data model:

Test {
test_that("new_email creates an unverified email", {
let e <- new_email("alice@example.com");
expect_equal(e$address, "alice@example.com");
expect_false(e$verified);
})

test_that("match handles all variants", {
let v <- .Verified("abc123");
let label <- match v {
.Unverified => "none",
.Pending => "pending",
.Verified(code) => code
};
expect_equal(label, "abc123");
})
}

Run typr build and devtools::test().

Summary of patterns

PatternUse caseExample
Record typeNamed, structured datatype Point <- list { x: int, y: int }
Tagged unionVariant datatype Shape <- .Circle(num) | .Square(num)
Option typeNullable valuestype Option<T> <- .Some(T) | .None
InterfacePolymorphic behaviortype Displayable <- interface { display: (Self) -> char }
Generic functionReusable logiclet id <- fn(x: T): T { x }

Where to go next