codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›From HTML/CSS/JS to React, Next.js, Tailwind›Step 10

Step 10

Step 10 — TypeScript in Depth (strict, narrowing, generics)

0 views

Step 10 — TypeScript in Depth (strict, narrowing, generics)

From step 5 through step 9, you have actually been using TypeScript the whole time. The props of a React component, the type of useState, the shape of form input — TS was holding all of it together behind the scenes. You just used it implicitly. In this final step we look at that tool head-on: what strict catches, how to narrow a type, how to receive external data safely, and how to write reusable types with generics.

What strict mode catches

"strict": true in tsconfig.json is a single line, but it contains a bundle of checks. The two you feel most:

1) No implicit any. When you do not write a type and the compiler fails to infer one, instead of silently falling through to any it flags an error.

// under strict: error — name's type is implicitly any
function greet(name) {
  return `Hi, ${name}`;
}

Write even a single type annotation on the parameter and the compiler stops inferring and accepts it.

// writing the type fixes it
function greet(name: string) {
  return `Hi, ${name}`;
}

2) null / undefined separation. With strict off, every type secretly contains null. Turn it on and only intentional places can be null.

function firstChar(s: string | null) {
  return s.charAt(0);   // error: s might be null
}

This error is not an annoyance — it is a kindness. It pulls a runtime error that would blank the screen back to right here, where you are writing the code. The fix is narrowing, in the next section.

Type narrowing

When a type holds several possibilities, like string | null, telling the compiler which one it is on this branch through a conditional is called narrowing. The tools are plain JavaScript syntax.

Narrowing a primitive with typeof:

function format(value: string | number) {
  if (typeof value === "string") {
    return value.toUpperCase();   // inside this block value is string
  }
  return value.toFixed(2);        // here it is narrowed to number
}

Narrowing an object shape with in:

type Cat = { meow(): void };
type Dog = { bark(): void };

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow();   // Cat
  } else {
    animal.bark();   // Dog
  }
}

Type guard functions — narrowing you write yourself. When the test is complex, extract it into a function with a value is Type return type.

type User = { id: string; name: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value
  );
}

if (isUser(data)) {
  console.log(data.name);   // inside this block data is User
}

When isUser returns true, the compiler treats data as User after that. Narrowing does not "change the type" — it "proves that on this branch it is this".

Receive external data as unknown

An API response, a localStorage value, a JSON.parse result — for values coming from outside your code, the compiler cannot know their type. Use any here and every check turns off, so a single typo survives all the way to runtime.

const data: any = await res.json();
console.log(data.usrename);   // a typo, but any lets it through → undefined at runtime

Receive it as unknown instead. unknown means "identity unknown", so it blocks you from doing anything before you narrow.

const data: unknown = await res.json();

console.log(data.name);       // error: unknown cannot be used as is — it blocks you

if (isUser(data)) {
  console.log(data.name);     // usable only after narrowing
}

any means "checking off"; unknown means "check, then use". Always default external data to unknown. (Real projects often use a validation library like zod instead of a hand-written type guard, but the principle is the same — validate after you receive.)

Generics basics

When you want to use the same logic for several types, taking the type as a parameter is a generic. Think of a function that returns the first element of an array.

function first(arr: any[]): any {   // any — the input type information is lost
  return arr[0];
}
const n = first([1, 2, 3]);   // n's type is any (a shame)

Introduce a type variable called <T> and the input type follows all the way to the output.

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const n = first([1, 2, 3]);        // n: number
const s = first(["a", "b"]);       // s: string

<T> is "a type the caller decides". When you pass an array at the call site, TS fills in T for you.

keyof — an object's keys as a type. It turns an object's key names into a union type.

type User = { id: string; name: string; age: number };
type UserKey = keyof User;   // "id" | "name" | "age"

Constraints (extends) — narrowing the range of T. A generic that is too free leaves nothing you can do inside it. With extends you get a promise that "T is at least this shape".

// T must be an object that has the key K — so obj[key] is safe
function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: "u1", name: "Jisoo", age: 20 };
const name = getField(user, "name");   // name: string
getField(user, "email");               // error: "email" is not a key

Thanks to K extends keyof T, passing a non-existent key is blocked at compile time. The combination of generic + constraint + keyof is the basic skeleton of a type-safe utility.

Utility types

Instead of writing a new type from scratch every time, derive it from an existing one and a fix in one place follows everywhere. The ones TS ships by default:

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

type UserCard  = Pick<User, "id" | "name">;   // a new type with only some fields
type SafeUser  = Omit<User, "email">;          // a new type with some fields removed
type UserDraft = Partial<User>;                // all fields made optional
type UsersById = Record<string, User>;         // a { [key: string]: User } map

When to use each, one line at a time:

  • Pick — narrowing a big type down to only the fields a screen needs (a list card, etc.).
  • Omit — sending data to the client with sensitive fields (email, etc.) removed.
  • Partial — input values where you send only some fields filled in, like an edit form.
  • Record — building a map that looks up objects by id.

The key point is that when the source User changes, the derived types follow automatically. Had you written UserCard by hand separately, it would be easy to forget to update it when User changes.

interface vs type — which one?

Both can describe an object shape, so it is confusing at first. A practical rule:

// an object shape you will extend → interface
interface Animal { name: string }
interface Dog extends Animal { bark(): void }

// you need a union or alias → type (interface cannot)
type Status = "idle" | "loading" | "done";
type Id = string | number;

An easy rule to remember — interface for a single object shape, type once you need a union or an alias. Where both work, follow the team convention; the moment you need a union, the choice settles naturally on type.

Try it

Recall the form you built in step 8. Write the shape of the data the form sends to the server as an interface, set the place that receives the server response as unknown, then narrow it with a type guard function. Then derive a card type from the response type with Pick, choosing only the fields to show on screen. Observe what the compiler flags with red underlines, and it becomes tangible why strict is a kindness.

Deeper

  • TypeScript and strict mode
  • Forms + Zod

Course wrap-up

You started with the three kinds of files — HTML / CSS / JS (steps 1–4). On top of that you built components with React 19 (step 5), grasped routes and data flow with the Next.js App Router (step 6), tied design together consistently with Tailwind tokens (step 7), and kindly received user mistakes with forms, validation, and loading UX (step 8). You uploaded, validated, transformed, and stored images safely (step 9), and finally you looked head-on at the TypeScript that had been holding all that code up (step 10) — the safety net of strict, type narrowing, receiving external data as unknown, generics, and utility types.

Now you can build, end to end, not a single static page but a living screen where users type and upload files, with stable code that types hold up. Two paths follow — connect a DB to Next and handle real data in the nextjs-fullstack course, or build a backend yourself in the backend-with-spring course. That is the end of the frontend foundations — well done climbing every stair to the top.

← Step 9

Step 9 — Image Upload and Optimization

🎉 You finished From HTML/CSS/JS to React, Next.js, Tailwind

What's next? Pick another course below.

Next: Build Your First Fullstack App with Next.js 16 →Browse all courses