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 awhenexpression 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 withmapNotNull. - 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 classkeyword 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 functionlet. - The
whenexpression, including matching against a numeric range within. - 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 withit.scores?.average()before grading. - Write a
topStudent(students: List<Student>): Student?function usingstudents.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
whenwith one that also returns aPair<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 asnullmarks.
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.