LearnAI ToolsCareerPractice BuildsPlayContact
KotlinBeginner~1.5 hours

Student Grading System

Process a list of students with nullable marks and produce grade summaries using null safety and when.

Null Safetywhen ExpressionsFunctions

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."

What You'll Build
  • 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
}
Example Usage

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"
}
Example Usage

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

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.