Objects and data types

Lesson 2 — R Programming

Lesson 2 of 12 Beginner ~90 min

Learning objectives

  • Name the six atomic types and test for them reliably
  • Predict the result of implicit coercion
  • Distinguish NA, NULL, NaN and Inf, and handle each correctly
  • Read and set attributes, including class, names and SAS-style labels
  • Explain copy-on-modify and why it matters for large study datasets
  • Choose the right assignment operator and know why = is not always <-

Everything is an object

In R, every value is an object, and every object has a type and a length. Even a single number:

x <- 42
typeof(x)
#> [1] "double"
length(x)
#> [1] 1

There is no scalar type in R. 42 is a numeric vector of length one. This single fact explains most of R’s behaviour that surprises people coming from SAS or Python.

The six atomic types

Type typeof() Literal Test
Logical "logical" TRUE, FALSE, NA is.logical()
Integer "integer" 1L, 42L is.integer()
Double "double" 1, 1.5, 1e6 is.double()
Character "character" "a", 'a' is.character()
Complex "complex" 1+2i is.complex()
Raw "raw" as.raw(1) is.raw()

In practice you will use four. Complex and raw appear in signal processing and binary file handling respectively.

typeof(TRUE)      #> "logical"
typeof(1L)        #> "integer"
typeof(1)         #> "double"      <- note: not integer!
typeof("1")       #> "character"

1 is a double, not an integer. Write 1L if you want an integer. This matters when comparing with data read from SAS or a database, and when interfacing with C or Java code.

Warningis.numeric() is not is.double()

is.numeric() returns TRUE for both integer and double. That is usually what you want, but it means is.numeric(1L) is TRUE while is.double(1L) is FALSE. Prefer is.numeric() for validation checks.

Coercion: the rule that catches everyone

A vector can only hold one type. When you combine types, R silently coerces to the most flexible one:

logical  ->  integer  ->  double  ->  character
c(TRUE, 1L)        #> 1 1              (integer)
c(1L, 1.5)         #> 1.0 1.5          (double)
c(1.5, "a")        #> "1.5" "a"        (character)
c(TRUE, "yes")     #> "TRUE" "yes"     (character)

This is why reading a CSV where one row of a numeric column contains "N/A" turns the entire column into character. The failure is silent and downstream arithmetic then fails in a confusing place.

The logical-to-integer coercion is genuinely useful:

x <- c(3, 8, 2, 9, 4)
sum(x > 5)     #> 2       how many are greater than 5
mean(x > 5)    #> 0.4     what proportion

TRUE becomes 1 and FALSE becomes 0, so sum() counts and mean() gives a proportion. You will use this constantly.

Explicit coercion

as.integer("42")     #> 42
as.numeric("3.14")   #> 3.14
as.character(3.14)   #> "3.14"
as.logical("TRUE")   #> TRUE
as.logical("T")      #> TRUE
as.logical("yes")    #> NA      <- careful

as.integer("abc")
#> [1] NA
#> Warning message: NAs introduced by coercion

That warning is important. A silent NA in a derived variable can propagate all the way to a results table. Check for it:

convert_numeric <- function(x) {
  out <- suppressWarnings(as.numeric(x))
  bad <- is.na(out) & !is.na(x) & trimws(x) != ""
  if (any(bad)) {
    stop("Cannot convert to numeric: ",
         paste(unique(x[bad]), collapse = ", "))
  }
  out
}

convert_numeric(c("1", "2", "3"))
#> [1] 1 2 3

convert_numeric(c("1", "N/A", "3"))
#> Error: Cannot convert to numeric: N/A
Importantas.numeric() on a factor
f <- factor(c("10", "20", "30"))
as.numeric(f)
#> [1] 1 2 3      <- the level codes, not the values!

as.numeric(as.character(f))
#> [1] 10 20 30   <- correct

This is the single most common silent data-corruption bug in R. If a numeric column arrives as a factor — from an old read.csv() call or a SAS import — always go through as.character().

Missing and special values

Four things that are not ordinary values:

Value Meaning Test
NA Missing, unknown is.na()
NULL Absent, zero-length is.null()
NaN Not a number (0/0) is.nan()
Inf, -Inf Infinity (1/0) is.infinite()
x <- c(1, NA, 3)
x > 2
#> [1] FALSE    NA  TRUE       comparison with NA gives NA

NA == NA
#> [1] NA                      not TRUE!

sum(x)
#> [1] NA
sum(x, na.rm = TRUE)
#> [1] 4

NA == NA returning NA is logically correct — two unknowns may or may not be equal — but it means you can never test for missingness with ==. Always use is.na().

NA is typed:

typeof(NA)             #> "logical"
typeof(NA_integer_)    #> "integer"
typeof(NA_character_)  #> "character"
typeof(NA_real_)       #> "double"

You need the typed versions inside ifelse(), case_when() and vapply() where R checks that all branches return the same type:

dplyr::case_when(
  x > 2  ~ "high",
  x <= 2 ~ "low",
  TRUE   ~ NA_character_
)

NULL is different from NA. NA is a missing value in a vector; NULL is the absence of a vector.

length(NA)     #> 1
length(NULL)   #> 0

c(1, NA, 3)    #> 1 NA 3
c(1, NULL, 3)  #> 1 3        NULL disappears

Assigning NULL to a list element or data frame column deletes it:

lst <- list(a = 1, b = 2)
lst$b <- NULL
names(lst)
#> [1] "a"

Attributes

Objects can carry metadata as named attributes. This is how R implements names, dimensions, classes and — crucially for clinical work — SAS variable labels.

x <- c(a = 1, b = 2, c = 3)
attributes(x)
#> $names
#> [1] "a" "b" "c"

names(x)
#> [1] "a" "b" "c"

Set an arbitrary attribute:

usubjid <- c("001", "002", "003")
attr(usubjid, "label") <- "Unique Subject Identifier"

attributes(usubjid)
#> $label
#> [1] "Unique Subject Identifier"

This is exactly what haven::read_sas() does with SAS labels and formats:

library(haven)
dm <- read_sas("dm.sas7bdat")

attributes(dm$AGE)
#> $label
#> [1] "Age"
#> $format.sas
#> [1] "BEST"

Extract all labels at once — useful for building a define.xml or a data dictionary:

labels <- vapply(dm, function(x) attr(x, "label") %||% NA_character_,
                 character(1))
tibble::tibble(variable = names(labels), label = unname(labels))
#> # A tibble: 8 x 2
#>   variable label
#>   <chr>    <chr>
#> 1 STUDYID  Study Identifier
#> 2 USUBJID  Unique Subject Identifier
#> ...
TipAttributes are fragile

Most operations drop attributes other than names, dim and class:

x <- c(1, 2, 3)
attr(x, "label") <- "Age"
y <- x * 2
attributes(y)
#> NULL     <- label is gone

This is why label preservation across a dplyr pipeline is a real problem in clinical programming, and why packages like labelled and xportr exist. See Metadata-driven programming.

class and dispatch

The class attribute controls which method a generic function calls:

x <- 1:10
class(x)                  #> "integer"
print(x)                  #> calls print.default

d <- as.Date("2026-01-15")
class(d)                  #> "Date"
unclass(d)                #> 20468   <- days since 1970-01-01
print(d)                  #> calls print.Date -> "2026-01-15"

A Date is just a double with a class attribute. Same for factors:

f <- factor(c("b", "a", "b"))
unclass(f)
#> [1] 2 1 2
#> attr(,"levels")
#> [1] "a" "b"

Knowing this demystifies a lot of otherwise strange behaviour.

Copy-on-modify

R has value semantics: assignment appears to copy.

x <- c(1, 2, 3)
y <- x
y[1] <- 100

x   #> 1 2 3      unchanged
y   #> 100 2 3

Under the hood R avoids copying until a modification actually happens:

library(lobstr)

x <- c(1, 2, 3)
obj_addr(x)      #> "0x55a1f2c3d4e8"
y <- x
obj_addr(y)      #> "0x55a1f2c3d4e8"    same memory, no copy yet

y[1] <- 100
obj_addr(y)      #> "0x55a1f2c40128"    now copied

The practical consequence: modifying a large object in a loop copies it every iteration.

# Slow: reallocates and copies on each iteration
result <- c()
for (i in 1:1e5) {
  result <- c(result, i * 2)
}

# Fast: allocate once, fill in place
result <- numeric(1e5)
for (i in 1:1e5) {
  result[i] <- i * 2
}

# Better: vectorise
result <- (1:1e5) * 2

On a 200,000-row lab dataset with 60 variables, the first pattern is the difference between a script that runs in seconds and one that runs in an hour.

Assignment operators

x <- 5      # standard assignment — use this
x = 5       # works, but reserved for function arguments by convention
5 -> x      # legal, occasionally readable at the end of a pipe
x <<- 5     # assigns in the enclosing environment — avoid
assign("x", 5)   # programmatic; rarely needed

Use <- for assignment and = only for arguments. The distinction becomes load-bearing here:

# Sets the argument
system.time(m <- mean(1:1e6))

# Different meaning entirely
mean(x = 1:10)   # x is the argument name
mean(x <- 1:10)  # creates x in the calling environment AND passes it

<<- walks up enclosing environments to find and modify a variable. It creates action at a distance and makes code very hard to reason about. If you find yourself reaching for it, you probably want to return a value instead.

Common mistakes

Mistake What happens Fix
x == NA Always NA is.na(x)
as.numeric(factor) Returns level codes as.numeric(as.character(f))
Assuming 1 is integer identical(1, 1L) is FALSE Use 1L, or compare with ==
if (x) where x has length > 1 Error in R ≥ 4.2 any() / all()
Growing vectors in a loop Quadratic time Pre-allocate or vectorise
Expecting labels to survive a pipeline Silently dropped labelled / xportr
0.1 + 0.2 == 0.3 FALSE (floating point) all.equal() or dplyr::near()

The last one deserves a demonstration:

0.1 + 0.2 == 0.3
#> [1] FALSE

print(0.1 + 0.2, digits = 20)
#> [1] 0.30000000000000004441

all.equal(0.1 + 0.2, 0.3)
#> [1] TRUE
dplyr::near(0.1 + 0.2, 0.3)
#> [1] TRUE

Never test floating point numbers for exact equality. This matters in independent programming double-checks, where two correct implementations can differ in the fifteenth decimal place.

Exercise 2.1 — Predict the type

Without running the code, state the typeof() and value of each:

a <- c(1, 2, "3")
b <- c(TRUE, FALSE, 1L)
c1 <- c(1, NA, 3)
d <- c(list(1), 2)
e <- c(1L, 2L) + 0.5
Show solution
typeof(c(1, 2, "3"))      #> "character"  — c("1","2","3")
typeof(c(TRUE, FALSE, 1L))#> "integer"    — c(1L, 0L, 1L)
typeof(c(1, NA, 3))       #> "double"     — NA is coerced to NA_real_
typeof(c(list(1), 2))     #> "list"       — list is more flexible than atomic
typeof(c(1L, 2L) + 0.5)   #> "double"     — integer + double promotes
The fourth is the one people miss: combining a list with anything produces a list, and the 2 becomes a one-element list component.

Exercise 2.2 — Safe conversion with reporting

Write safe_numeric(x) that converts a character vector to numeric, returns the numeric vector, and issues a warning (not an error) listing the distinct values that could not be converted. Empty strings and NA should convert to NA without warning.

Show solution
safe_numeric <- function(x) {
  x_chr <- as.character(x)
  blank <- is.na(x_chr) | trimws(x_chr) == ""

  out <- suppressWarnings(as.numeric(x_chr))
  failed <- is.na(out) & !blank

  if (any(failed)) {
    warning(
      "Could not convert ", sum(failed), " value(s) to numeric: ",
      paste(sort(unique(x_chr[failed])), collapse = ", "),
      call. = FALSE
    )
  }
  out
}

safe_numeric(c("1", "2", "", NA, "3"))
#> [1]  1  2 NA NA  3

safe_numeric(c("1", "N/A", "<LLOQ", "3", "N/A"))
#> [1]  1 NA NA  3 NA
#> Warning: Could not convert 3 value(s) to numeric: <LLOQ, N/A
Note that the warning reports distinct values, sorted — with 200,000 lab records you do not want 40,000 lines of warning. This function is a reasonable starting point for handling below-limit-of-quantification results, which you would normally want to flag rather than discard.

Exercise 2.3 — Preserve labels through an operation

Write with_label(x, expr)… actually, write a function copy_labels(target, source) that copies the label attribute from every column of source onto the matching column of target, leaving columns that do not exist in source untouched. Then demonstrate it recovering labels lost by a dplyr::mutate().

Show solution
copy_labels <- function(target, source) {
  common <- intersect(names(target), names(source))
  for (nm in common) {
    lbl <- attr(source[[nm]], "label")
    if (!is.null(lbl)) {
      attr(target[[nm]], "label") <- lbl
    }
  }
  target
}

# Demonstration
dm <- data.frame(USUBJID = c("001", "002"), AGE = c(45, 52))
attr(dm$USUBJID, "label") <- "Unique Subject Identifier"
attr(dm$AGE, "label")     <- "Age"

dm2 <- dplyr::mutate(dm, AGE = AGE + 1)
attr(dm2$AGE, "label")
#> NULL                     <- lost

dm3 <- copy_labels(dm2, dm)
attr(dm3$AGE, "label")
#> [1] "Age"                <- restored
In production, use labelled::copy_labels() or the xportr family rather than hand-rolling this — they handle formats, widths and types as well. But knowing it is a five-line function built on attr() removes the magic.

Recap

  • There are no scalars; 42 is a length-one double vector
  • Coercion follows logical → integer → double → character, silently
  • is.na(), never == NA; NULL is absence, NA is missingness
  • Attributes carry metadata; most operations drop everything except names, dim and class
  • A Date is a double with a class; a factor is an integer with levels
  • Copy-on-modify makes growing objects in loops quadratic — pre-allocate
  • Never compare doubles with ==; use dplyr::near()

Next: Vectors, lists, matrices and data frames.

Back to top