Beginner

Variables and Types in V (Vlang)

Learn how V handles variables, primitive types, immutability, type inference, and conversions with practical Docker-ready examples

V is a statically typed, strongly typed language that leans heavily on type inference. You rarely need to write type names, yet every value still has a fixed compile-time type. V also takes a strong stance on mutability: every binding is immutable unless you explicitly mark it mut. This combination makes V code feel as light as Go or Python while keeping the safety of a typed compiler.

In this tutorial you will learn how to declare variables, work with V’s primitive types, convert between numeric types, and use module-level constants. We will also look at how V handles the absence of a value – there is no null in V, so optional results use a different mechanism.

The examples below use V’s preferred style: short variable declarations with :=, single-quoted strings, and tab indentation (which v fmt would enforce automatically).

Declaring Variables

V uses the walrus-style := operator to declare and initialize a variable in one step. The type is inferred from the right-hand side. Reassignment is only allowed when the variable was declared mut.

Create a file named variables.v:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fn main() {
	// Immutable bindings (the default)
	name := 'Ada'
	age := 36
	height := 1.70

	// Mutable binding -- 'mut' is required for reassignment
	mut score := 0
	score = score + 10
	score += 5

	println('name = ${name}')
	println('age = ${age}')
	println('height = ${height}')
	println('score = ${score}')
}

Try removing mut from score and the V compiler will refuse to build the program. Immutability is not a warning in V – it is a hard rule.

Primitive Types and Type Inference

V infers a default type for every literal: integers become int (a 32-bit signed integer), floating-point literals become f64, string literals become string, and true/false become bool. You can override the default with an explicit type annotation or a type conversion.

Create a file named types.v:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
	// Inferred types
	count := 42          // int
	ratio := 3.14        // f64
	label := 'vlang'     // string
	ready := true        // bool

	// Explicit type annotation
	big_number := i64(9_000_000_000)
	small_byte := u8(255)
	precise := f32(2.5)

	// V can print the type of any value at compile time
	println('count is ${typeof(count).name}')
	println('ratio is ${typeof(ratio).name}')
	println('label is ${typeof(label).name}')
	println('ready is ${typeof(ready).name}')
	println('big_number is ${typeof(big_number).name}')
	println('small_byte is ${typeof(small_byte).name}')
	println('precise is ${typeof(precise).name}')
}

V offers a full set of sized numeric types: i8, i16, int (32-bit), i64, u8, u16, u32, u64, plus f32 and f64. Underscores in literals (9_000_000_000) are purely cosmetic and improve readability.

Type Conversions

V does not perform implicit numeric conversions. Mixing an int and an f64 directly is a compile-time error – you must convert one of the operands. Conversions look like a function call: int(x), f64(x), i64(x), and so on.

Create a file named conversions.v:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
	x := 7         // int
	y := 2.0       // f64

	// Convert before mixing types
	average := f64(x) / y
	rounded := int(average)

	// String <-> number conversions use the strconv module via methods
	port_str := '8080'
	port := port_str.int()

	number := 255
	hex := number.hex()

	println('average = ${average}')
	println('rounded = ${rounded}')
	println('port = ${port}')
	println('hex of ${number} = ${hex}')
}

The .int() method on a string parses it (returning 0 if the string is not a valid integer), while .hex() on an integer formats it as a lowercase hexadecimal string. These string methods are part of V’s built-in string and int types, so no import is needed.

Constants

Variables in V are scoped to a function body. To define a value that lives at module scope and is visible to every function in the file, use a const block. Constants must be initialized with a compile-time-known expression.

Create a file named constants.v:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const (
	pi          = 3.14159
	max_retries = 5
	app_name    = 'codearchaeology'
)

fn area_of_circle(radius f64) f64 {
	return pi * radius * radius
}

fn main() {
	println('app: ${app_name}')
	println('max_retries: ${max_retries}')
	println('area(2.0) = ${area_of_circle(2.0)}')
}

Constants are always immutable. You cannot mark them mut, and there is no way to reassign them at runtime. Their type is inferred from the initializer just like a regular := binding.

Optional Values Instead of Null

V has no null, no nil, and no undefined. When a value may legitimately be missing, the type is wrapped with ? and the caller uses an or block to handle the absent case. This forces you to deal with missing values at the type-system level rather than with a runtime exception.

Create a file named optionals.v:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fn lookup(id int) ?string {
	if id == 1 {
		return 'Alice'
	}
	if id == 2 {
		return 'Bob'
	}
	return none
}

fn main() {
	// Use 'or' to provide a default for the missing case
	name1 := lookup(1) or { 'unknown' }
	name2 := lookup(99) or { 'unknown' }

	println('id 1 -> ${name1}')
	println('id 99 -> ${name2}')
}

The function’s return type is ?string, meaning “a string or nothing.” Returning none signals absence, and the or { ... } block runs only when the result is none. There is no way to forget to handle the empty case – the compiler will not let you assign ?string to a string directly.

Running with Docker

Each example is an independent program. Pull the V image once and run any of the files above.

1
2
3
4
5
6
7
8
9
# Pull the official V image
docker pull thevlang/vlang:alpine

# Run any of the examples
docker run --rm -v $(pwd):/app -w /app thevlang/vlang:alpine v run variables.v
docker run --rm -v $(pwd):/app -w /app thevlang/vlang:alpine v run types.v
docker run --rm -v $(pwd):/app -w /app thevlang/vlang:alpine v run conversions.v
docker run --rm -v $(pwd):/app -w /app thevlang/vlang:alpine v run constants.v
docker run --rm -v $(pwd):/app -w /app thevlang/vlang:alpine v run optionals.v

Expected Output

Running variables.v:

name = Ada
age = 36
height = 1.7
score = 15

Running types.v:

count is int
ratio is f64
label is string
ready is bool
big_number is i64
small_byte is u8
precise is f32

Running conversions.v:

average = 3.5
rounded = 3
port = 8080
hex of 255 = ff

Running constants.v:

app: codearchaeology
max_retries: 5
area(2.0) = 12.56636

Running optionals.v:

id 1 -> Alice
id 99 -> unknown

Key Concepts

  • Immutable by default: name := 'Ada' creates an immutable binding. Use mut name := ... and reassign with = only when mutation is intended.
  • Strong static typing with inference: Every variable has a fixed compile-time type, but you almost never write the type yourself – V infers it from the initializer.
  • No implicit numeric conversions: Mixing int and f64 is a compile-time error. Convert explicitly with f64(x) or int(x).
  • Sized numeric types: V offers i8, i16, int, i64, u8u64, f32, and f64. The default for an integer literal is int; for a float literal it is f64.
  • const for module-level values: Constants live outside any function and must be initialized with a compile-time expression.
  • No null, ever: Optional values use ?Type and none. The caller must handle the empty case with an or block, which makes missing values impossible to forget.
  • String interpolation with ${}: Any expression – including method calls like typeof(x).name – can be embedded directly in a single-quoted string.
  • No unused variables: V’s compiler rejects programs that declare a variable and never read it, helping keep code intentional.

Running Today

All examples can be run using Docker:

docker pull thevlang/vlang:alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining