Constants, variables and inferred types
Think of labeled jars in a kitchen. A jar has a name on the label (“sugar”), it holds one kind of thing (you don’t pour soup into the sugar jar), and some jars are sealed once filled while others get refilled every day.
In code, that jar is a variable: a name, a type (what kind of value it holds), and a value. A sealed jar is a constant: once it has a value, it keeps it.
All four languages are statically typed (TypeScript at compile time only) with local type inference: the type of a declaration is inferred from its initializer, not from later uses. Declarations split on two axes: rebindable or not (var vs let/val/final/const) and the type, explicit or inferred. Neither axis says anything about whether the object is mutable; that’s covered in Value types vs reference types.
let name = "Ana" // constant: can't be reassigned
var score = 0 // variable: can change
score += 10
let price: Double = 3 // the type written explicitlyval name = "Ana" // constant: can't be reassigned
var score = 0 // variable: can change
score += 10
val price: Double = 3.0 // the type written explicitlyfinal String name = "Ana"; // constant: can't be reassigned
int score = 0; // variable: can change
score += 10;
var city = "Novi Sad"; // local type inference (Java 10+)const name = "Ana"; // constant: can't be reassigned
let score = 0; // variable: can change
score += 10;
const price: number = 3; // the type written explicitly- Use a constant by default (
let,val,final,const). If you later try to change it, the compiler tells you, and you decide whether that change is really what you want. - Use a variable (
var, orletin TypeScript) only for values that really change: a counter, a running total. - Type inference: when you write
let name = "Ana", the compiler sees a string and givesnamethe typeString. You don’t have to write it. Java needs the type written, except withvarfor local variables.
TypeScript note
TypeScript’s names are confusing at first: const is the constant, and let is the variable. The old var still exists but behaves differently (it ignores blocks); don’t use it.
When to write the type yourself
Inference works from the value on the right. When there’s no value, or the value could mean several types, write the type:
var names: [String] = [] // empty: nothing to infer from
let ratio: Double = 1 // 1 alone would be an Int
let id: Int64 = 9_000_000_000 // a specific sizeval names = mutableListOf<String>() // empty: nothing to infer from
val ratio: Double = 1.0 // Kotlin never turns 1 into a Double
val id: Long = 9_000_000_000 // a specific sizeList<String> names = new ArrayList<>(); // empty: the type says what goes in
double ratio = 1; // Java widens int 1 to double
long id = 9_000_000_000L; // L: the literal itself is a longconst names: string[] = []; // empty: [] alone would be any[] or never[]
const ratio = 1; // all numbers are one type: number
const id = 9_000_000_000n; // n: a bigint, for whole numbers above 2^53Predict the output
Which type does the compiler give each of these? let a = 7, let b = 7.0, let c = 7 / 2
let a = 7
let b = 7.0
let c = 7 / 2val a = 7
val b = 7.0
val c = 7 / 2var a = 7;
var b = 7.0;
var c = 7 / 2;const a = 7;
const b = 7.0;
const c = 7 / 2;7 is an Int, 7.0 a Double, and 7 / 2 divides two Ints, so it's an Int too: 3, not 3.5. The type comes from the values, not from what you hoped for.
7 is an Int, 7.0 a Double, and 7 / 2 divides two Ints, so it's an Int too: 3, not 3.5.
With var, 7 is an int, 7.0 a double, and 7 / 2 divides two ints, so it's an int too: 3, not 3.5.
TypeScript has one type for all numbers, number, so all three are number (shown here as "Double"), and 7 / 2 is 3.5. Whole and decimal numbers aren't separate types, which avoids this trap but brings others (see the next part).
In your editor you can check what was inferred: Option-click a name in Xcode, hover in Android Studio or IntelliJ, hover in VS Code.
Scope: where a name exists
A variable exists from where it’s declared to the end of the block ({ … }) it’s in. A variable declared inside an if or a loop is gone after it, which is useful: it can’t be changed by accident later.
var total = 0
for price in [3, 5, 8] {
let withTax = price * 12 / 10 // exists only inside the loop
total += withTax
}
print(total) // 18var total = 0
for (price in listOf(3, 5, 8)) {
val withTax = price * 12 / 10 // exists only inside the loop
total += withTax
}
println(total) // 18int total = 0;
for (int price : List.of(3, 5, 8)) {
int withTax = price * 12 / 10; // exists only inside the loop
total += withTax;
}
System.out.println(total); // 18let total = 0;
for (const price of [3, 5, 8]) {
const withTax = Math.floor((price * 12) / 10); // exists only inside the loop
total += withTax;
}
console.log(total); // 18(3 × 12 / 10 is 3, 5 × 12 / 10 is 6, 8 × 12 / 10 is 9: integer division drops the decimals, which is the topic of the next part.)
What inference does and doesn’t do
- Local, forward-only. The type comes from the initializer.
var x = 0is an integer forever, even if you later assign a decimal (a compile error in Swift, Kotlin and Java; in TypeScript,let x = 0isnumber, so a decimal fits). - Literals get defaults. Swift integer literals default to
Int, float literals toDouble, but a literal adapts to an expected type (let d: Double = 1is fine). Kotlin and Java literals have fixed types (1isInt/int; Java widens it on assignment, Kotlin doesn’t). - Widening the declared type. TypeScript infers
const x = "a"as the literal type"a"andlet x = "a"asstring. Object literal properties widen ({ kind: "a" }giveskind: string) unless you useas constorsatisfies. - Public APIs should state types. Inferred return types of public functions turn implementation changes into API changes. Kotlin’s explicit API mode and Swift library evolution both push you to write them.
Deep diveWhy Java's var stops at local variablesInterview favorite
Java 10 added var only for local variables (and lambda parameters in 11): never for fields, parameters or return types. The reasoning was that local inference can’t leak into a class’s contract, while field and signature inference would let a change in an initializer silently change an API and break callers in other files. Kotlin and Swift infer property and return types too, which is why both nudge you to annotate public declarations.
- Constants by default (
let,val,final,const); they prevent rebinding, not mutation of the object. - Inference is local and from the initializer. Annotate empty collections, literals that need a non-default type, and public API surfaces.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Constant / variable | let / var | val / var | final / plain | const / let |
| Inference scope | locals, properties, closures | locals, properties, expression-bodied returns | locals only (var) | everywhere; literal types for const |
| Integer literal | adapts to context (Int default) | Int (or Long if too big) | int (L for long) | number (n for bigint) |
Check this part
Which declares a value that can't be reassigned?
When must you write the type yourself?
let/val/finalon a variable holding a list means…Why annotate the return types of public functions even when inference works?