Overview
Real student data is never perfectly complete — a mark might genuinely not exist yet because a student has not sat an exam, transferred in mid-term, or been granted a deferral. Kotlin's type system makes that distinction explicit at compile time: `val marks: Int` can never be missing, but `val marks: Int?` can, and the compiler will not let you use a nullable value as if it were guaranteed to exist without first proving you have handled the `null` case. That single character, `?`, is the difference between a `NullPointerException` waiting to happen and a program the compiler has already verified is safe.
By the end of this tutorial you will have a console application that processes a list of `Student` records with genuinely nullable `marks: Int?`, converts each present score into a letter grade with a `when` expression over a range, and reports "No marks recorded" for anyone still missing a score — all without a single manual null check written as `if (x != null)`. Instead you will use Kotlin's safe-call operator `?.`, the elvis operator `?:`, and the scope function `let` together, which is the idiomatic combination for "do something with this value only if it exists, otherwise fall back to a default."
- A `data class Student(val name: String, val marks: Int?)` with genuinely nullable marks.
- A `gradeFor(score: Int)` function that maps a 0-100 score to a letter grade with a `when` expression over ranges.
- A `gradeSummary(student: Student)` function combining `?.let { }` and `?:` to safely format a result.
- A `classAverage(students: List<Student>)` function that skips nullable marks with `mapNotNull`.
- A `printReport()` function that prints one grade line per student, including those with no marks.
- A small hard-coded roster driving the program from `main()`.
Prerequisites
- Data classes — the `data class` keyword and constructor properties.
- Nullable types — the `?` suffix on a type, and why Kotlin requires a value to be proven non-null before it is used unsafely.
- Null safety operators — the safe call `?.`, the elvis operator `?:`, and the scope function `let`.
- The `when` expression, including matching against a numeric range with `in`.
- Function basics — parameters, return types, and Kotlin's single-expression function syntax.
Project Structure
The whole program lives in a single file, `StudentGradingSystem.kt`, containing one data class, `Student`, three top-level functions (`gradeFor`, `gradeSummary`, `classAverage`), and `fun main()`. Keeping `gradeFor` and `gradeSummary` as free functions rather than methods on `Student` is a deliberate choice: `Student` itself stays a pure, minimal data holder, while the grading logic — which could plausibly change independently of what a student record even looks like — lives separately and is easy to test or swap out on its own.
Step 1: Define the Student Data Class With Nullable Marks
The key design decision in this whole project is `val marks: Int?` instead of `val marks: Int`. That trailing `?` is not decoration — it changes `marks`'s type to "either an `Int`, or the absence of one," and from this point forward the Kotlin compiler refuses to compile any code that treats `student.marks` as a plain `Int` without first narrowing it, forcing every later step to deal with the missing case explicitly instead of letting it slip through as an accidental crash.
// marks is Int? (nullable), not Int, because a real student roster can have// students who have not been marked yet — the type itself documents that a// missing score is an expected, ordinary case, not an error condition.data class Student( val name: String, // Student's full name val marks: Int? // Nullable: null means "no marks recorded yet" for this student)Step 2: Map a Score to a Letter Grade With when
`gradeFor()` takes a plain, non-nullable `Int` — by the time a score reaches this function, the caller in Step 3 will already have proven it is not null, so `gradeFor` itself never has to think about nullability at all. The `when` expression matches ranges with the `in` operator, and because every branch returns a `String`, the whole `when` block evaluates to a `String` that the function can return directly with Kotlin's single-expression `= when (...) { ... }` syntax.
// A single-expression function: the body is one when expression, so "=" is// used instead of a { return ... } block. score is a plain Int here, never// nullable — by the time this is called, the caller has already handled null.fun gradeFor(score: Int): String = when (score) { in 90..100 -> "A" // "in" tests range membership; matches 90 through 100 inclusive in 80..89 -> "B" in 70..79 -> "C" in 60..69 -> "D" in 0..59 -> "F" else -> "Invalid" // Anything outside 0-100 is not a real percentage score}Click Run to see what this code prints.
Step 3: Handle Missing Marks With Safe Calls and Elvis
`gradeSummary()` is where the null-safety idiom comes together in one line: `student.marks?.let { gradeFor(it) }` only runs the lambda — and only calls `gradeFor(it)` — when `student.marks` is non-null; if it is `null`, the whole `?.let { }` expression short-circuits to `null` without ever calling `gradeFor`. The `?:` (elvis operator) immediately after supplies what to use instead when that happens: `"No marks recorded"`. Together, `marks?.let { gradeFor(it) } ?: "No marks recorded"` reads almost like the English sentence it represents, and there is no `if (marks != null)` anywhere in it.
fun gradeSummary(student: Student): String { // ?. (safe call): only enters the lambda if marks is non-null. // let { }: runs the lambda with "it" bound to the non-null Int. // ?: (elvis): supplies the fallback string when the left side was null. val grade = student.marks?.let { gradeFor(it) } ?: "No marks recorded" return "${student.name}: $grade"}Click Run to see what this code prints.
Step 4: Summarize a Class of Students
`classAverage()` needs to compute an average over only the students who actually have marks, silently excluding anyone with `null`. `students.mapNotNull { it.marks }` does exactly that in one call: it maps each student to their `marks`, then drops every `null` result, leaving a plain `List<Int>` with no nullability left to worry about at all. `average()` on an empty list returns `Double.NaN` rather than throwing, so the function still guards the empty-list case explicitly for a cleaner "N/A" result.
fun classAverage(students: List<Student>): Double? { val scores = students.mapNotNull { it.marks } // Keeps only the non-null marks; nulls are silently filtered out if (scores.isEmpty()) { return null // No marked students at all; the caller decides how to display that } return scores.average() // average() over a confirmed non-empty List<Int>}Step 5: Build the Program Entry Point
`main()` builds a small hard-coded roster mixing students who have marks with one who does not, then prints a summary line per student with `gradeSummary()` from Step 3, followed by the class average from Step 4 — using the same `?.let { } ?: ` pattern one more time to format a `Double?` only when it is actually present.
fun main() { val roster = listOf( Student("Ananya Rao", 88), Student("Vikram Shah", null), // No marks recorded for this student yet Student("Priya Nair", 95), Student("Rohan Verma", 42), )
println("===== STUDENT GRADING SYSTEM =====") for (student in roster) { println(gradeSummary(student)) // Reuses Step 3's null-safe summary for every student, uniformly }
val average = classAverage(roster) // Formats the average only if one could be computed; falls back to "N/A" otherwise, same ?.let/?: idiom as Step 3 println("\nClass average: ${average?.let { "%.2f".format(it) } ?: "N/A"}")}Complete Code
Here is the full program with every declaration assembled in the correct order, ready to save as `StudentGradingSystem.kt` and run with `kotlinc StudentGradingSystem.kt -include-runtime -d grading.jar && java -jar grading.jar`.
data class Student( val name: String, val marks: Int?)
fun gradeFor(score: Int): String = when (score) { in 90..100 -> "A" in 80..89 -> "B" in 70..79 -> "C" in 60..69 -> "D" in 0..59 -> "F" else -> "Invalid"}
fun gradeSummary(student: Student): String { val grade = student.marks?.let { gradeFor(it) } ?: "No marks recorded" return "${student.name}: $grade"}
fun classAverage(students: List<Student>): Double? { val scores = students.mapNotNull { it.marks } if (scores.isEmpty()) { return null } return scores.average()}
fun main() { val roster = listOf( Student("Ananya Rao", 88), Student("Vikram Shah", null), Student("Priya Nair", 95), Student("Rohan Verma", 42), )
println("===== STUDENT GRADING SYSTEM =====") for (student in roster) { println(gradeSummary(student)) }
val average = classAverage(roster) println("\nClass average: ${average?.let { "%.2f".format(it) } ?: "N/A"}")}Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a `List<Int>?` field for multiple assessment scores per student, and average them with `it.scores?.average()` before grading.
- Write a `topStudent(students: List<Student>): Student?` function using `students.maxByOrNull { it.marks ?: -1 }` so students with no marks never win.
- Add a `attendance: Int?` field and combine it with marks using nested `?.let { }` calls to compute a combined eligibility flag.
- Replace the letter-grade `when` with one that also returns a `Pair<String, String>` of grade and remark ("A" to "Excellent", etc.).
- Read the roster from user input with `readLine()` instead of a hard-coded list, treating a blank line as `null` marks.
Summary
You built a student grading system where a nullable `Int?` makes "no marks yet" a case the compiler forces you to handle, not a runtime surprise, and the combination of `?.`, `let`, and `?:` let you express that handling in a single readable line instead of an `if (x != null)` block. The `when`-over-a-range pattern in `gradeFor()` and the `mapNotNull` filtering in `classAverage()` are both idioms you will reach for constantly in Kotlin — anywhere you need to classify a value into buckets, or anywhere a collection can contain nulls you need to safely ignore.