A form that “works on my machine” and breaks at the school is almost always the same gap: the front sends one field, the back expects another, and nobody wrote the rule.
I don’t start with the screen. I start with the contract. An attendance mark is a student, a day, a boolean. Nothing else. If the day is in the future, we refuse. If the student does not exist, we refuse. The screen does not get to invent a third shape.
The same object, both sides
On the Django side, the serializer is the door. It does not quietly “clean” the payload. It says no.
from datetime import date
from rest_framework import serializers
class PresenceIn(serializers.Serializer):
eleve_id = serializers.IntegerField(min_value=1)
jour = serializers.DateField()
present = serializers.BooleanField()
def validate_jour(self, value):
if value > date.today():
raise serializers.ValidationError(
"An attendance mark is not set in the future."
)
return value
On the React side, the type uses the same names. No studentId on one side and eleve_id on the other. A rename is a second API in disguise.
export type Presence = {
eleve_id: number;
jour: string;
present: boolean;
};
export async function markPresence(input: Presence): Promise<Presence> {
const response = await fetch("/api/presences/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.jour?.[0] ?? "Attendance refused.");
}
return response.json() as Promise<Presence>;
}
Example
Monday 6 October, student 12, present. The body that leaves:
{ "eleve_id": 12, "jour": "2026-10-06", "present": true }
The same call with "jour": "2026-12-01" must not create a row. The response carries the serializer message, and the screen shows it under the field. Not a generic alert.
The commands that check it
I don’t trust the screen to tell me the back holds. I hit the view directly.
curl -s -X POST http://127.0.0.1:8000/api/presences/ \
-H "Content-Type: application/json" \
-d '{"eleve_id": 12, "jour": "2026-12-01", "present": true}'
The response has to be a 400, with the sentence about the future. Only then do I wire the form.
python manage.py shell -c "
from datetime import date
from app.serializers import PresenceIn
payload = {'eleve_id': 12, 'jour': str(date.today()), 'present': True}
form = PresenceIn(data=payload)
print(form.is_valid(), form.validated_data if form.is_valid() else form.errors)
"
When both commands say the same thing as the TypeScript type, the contract is set. The screen can change without rewriting the rules, and the rules can change without breaking the screen in silence.