Hoi hoi! π
I'm @nyaomaru, a frontend engineer who is obsessed with gamba. πΈπ¦
Recently, I was thinking about a very common TypeScript problem.
Suppose some value comes from:
- an API response
JSON.parse- local storage
- a message payload
- third-party code
What type should it have before we actually verify it?
Probably this
declare const input: unknown;
That part is easy.
The interesting question is what comes next.
How do we safely turn that unknown into something useful?
And do we really need a full schema library every time?
Let's look at it! π
π§± unknown Is Doing Its Job
Suppose we have
declare const input: unknown;
input.name;
// Error
TypeScript refuses to let us access name.
At first this can feel annoying.
But TypeScript is actually telling us something important
We have a value.
We do not know what it is yet.
That is exactly what an untrusted boundary value should mean.
An HTTP response does not become a User just because our application expects a User.
Neither does parsed JSON.
So I like to think of unknown like this
unknownis not missing type information.It is an honest description of a value we have not verified yet.
The problem is not unknown.
The problem is how we establish trust.
π The Easy Escape: as User
There is a very short way out.
type User = {
id: string;
name: string;
};
declare const input: unknown;
const user = input as User;
user.name.toUpperCase();
TypeScript is happy.
Nice! π
Except, nothing was actually checked.
This still works
const input = {
id: "user-1",
name: 123,
} as unknown;
const user = input as User;
user.name.toUpperCase();
// π₯ Runtime error
as User changes what the compiler believes.
It does not inspect the runtime value.
It does not add missing properties.
It does not turn 123 into a string.
So if runtime trust matters, a type assertion is not validation.
We need an actual runtime check.
π‘οΈ A Type Guard Connects Runtime and TypeScript
Let's start small.
With is-kit, we can build a guard like this
import { isString, oneOfValues, struct } from "is-kit";
const isUser = struct({
id: isString,
name: isString,
role: oneOfValues("admin", "member"),
});
Now we can pass unknown directly to it
declare const input: unknown;
if (isUser(input)) {
input.name.toUpperCase();
// input:
// Readonly<{
// id: string;
// name: string;
// role: "admin" | "member";
// }>
}
This is the part I like.
The same check does two jobs
runtime
β does this value actually match?
TypeScript
β what is this value after the check succeeds?
There is no separate schema definition and no type assertion.
We just compose small runtime predicates.
π§© The Shape Can Grow Naturally
Real payloads are usually a little more complicated.
Maybe a user has:
- an optional profile
- a nullable bio
- a list of tags
We can keep composing guards
import {
arrayOf,
isString,
nullable,
oneOfValues,
optionalKey,
struct,
} from "is-kit";
const isProfile = struct({
displayName: isString,
bio: optionalKey(nullable(isString)),
});
const isUser = struct({
id: isString,
role: oneOfValues("admin", "member"),
profile: optionalKey(isProfile),
tags: arrayOf(isString),
});
Then
isUser({
id: "user-1",
role: "member",
tags: ["typescript", "guards"],
});
// true
But
isUser({
id: "user-1",
role: "owner",
tags: ["typescript"],
});
// false
Each piece is still just a guard.
That means we can test it independently, reuse it somewhere else, or compose it into something larger.
There is also an important difference here
optionalKey(...)
means
the property may be absent
while
nullable(...)
means
the value may be null
Those are different runtime contracts, so I prefer keeping them separate.
π¦ What Does safeParse Add?
A direct guard is often enough.
if (isUser(input)) {
renderUser(input);
}
But sometimes I want the validation result itself to move through the program.
For example
const result = safeParse(isUser, input);
if (!result.valid) {
return { status: 400 as const };
}
return {
status: 200 as const,
user: result.value,
};
safeParse gives us a small tagged result
{
valid: (true, value);
}
or
{
valid: false;
}
So TypeScript can narrow it with a normal branch.
The important part is what safeParse does not do.
It does not:
- clone the value
- coerce the value
- generate a new object
- transform invalid data into valid data
On success, it returns the same value after the guard has accepted it.
So I think of the difference like this
guard
β boolean + narrowing
safeParse
β tagged result + validated value
Same runtime rule.
Different control-flow shape.
π§ What About JSON?
This is one of the most common boundaries.
const value = JSON.parse(body);
The awkward part is that JSON.parse returns any.
So now the unsafe value can spread through the program before we validate it.
Instead, we can keep the boundary small
import { safeJsonParse } from "is-kit";
declare const body: string;
const result = safeJsonParse(body, isUser);
if (!result.valid) {
return { status: 400 as const };
}
renderUser(result.value);
Conceptually, the flow is
JSON text
β
decode
β
unknown
β
guard
β
validated value
Invalid JSON and guard failures both produce
{
valid: false;
}
And again, nothing is coerced just to make the value pass.
I like this because the unsafe part stays right at the decode boundary.
πͺ Should Extra Keys Be Allowed?
There is another small design choice that appears at object boundaries.
Suppose we expect
{
username: string;
password: string;
}
Should this also be accepted?
{
username: "Neko",
password: "secret",
admin: true,
}
With struct, extra keys are allowed by default.
That is often useful for API responses because a server may add a new field without breaking an older client.
But sometimes we really want a closed object shape.
Then we can use
const isCredentials = struct(
{
username: isString,
password: isString,
},
{ exact: true },
);
Now
isCredentials({
username: "Neko",
password: "secret",
});
// true
while
isCredentials({
username: "Neko",
password: "secret",
admin: true,
});
// false
The important part is not that one mode is always better.
It is that the boundary should express the contract we actually want.
π€ So Do We Still Need Schema Libraries?
Yes.
Sometimes absolutely.
A type guard toolkit and a schema library overlap a little, but they are not trying to solve exactly the same problem.
For example, a schema library is probably the better choice when we need:
- structured validation errors with field paths
- multiple validation issues at once
- coercion
- default values
- transformations
- schema-generated types or external artifacts
- deep integration with a form or API framework
Those are larger validation workflows.
is-kit asks a narrower question
Does this value satisfy this runtime predicate?
If yes, what can TypeScript safely narrow it to?
That narrower model is sometimes exactly what I want.
For example
| Approach | Good fit |
|---|---|
Inline typeof checks |
One small local condition |
| Type guards | Reusable boolean validation and narrowing |
| Schema library | Rich validation, transformation, and schema workflows |
So I don't think the question is
Type guards or schema librariesβwhich one wins?
The more useful question is
What is the smallest validation model that solves this boundary?
π― The Important Part
I started with a simple value
declare const input: unknown;
The tempting shortcut was
const user = input as User;
But that only changes TypeScript's belief.
It does not establish runtime trust.
A runtime guard gives us a much more useful flow
unknown
β
runtime check
β
narrowed value
And when we want a result-oriented API
unknown
β
guard
β
safeParse
β
{ valid: true, value }
or
{ valid: false }
No hidden conversion.
No assertion pretending the check already happened.
Just small runtime rules that TypeScript can understand too. πΈ
If you want to see the full guide, including JSON boundaries, exact objects, and custom domain refinements:
And if you want to explore is-kit itself:
nyaomaru
/
is-kit
Build small guards. Compose them. A lightweight, zero-dependency toolkit for building reusable TypeScript type guards that compose, refine, and preserve natural narrowing. Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨.
is-kit
Build small guards. Compose them.
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
is-kit is not just a collection of isX helpers. Its main focus is composing
small runtime checks into reusable guards while preserving useful TypeScript
narrowing.
It helps you write small isFoo functions, compose them into richer runtime
checks, refine properties on values you already know about, and keep
TypeScript narrowing natural inside regular control flow. Use it at runtime
boundaries when needed, without requiring a schema-first workflow.
Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨ without asking you to adopt a heavy schema workflow.
- Build and reuse typed guards
-
Compose guards with
and,or,not,oneOf - Use
refineKeyto narrow a child property while preserving its parent type - Validate object shapes and collections when that is useful
-
Parse or assert
unknownvalues without a large schema framework
Bestβ¦
If it looks useful, a β on GitHub is always very welcome!
Thanks for reading! π

Top comments (8)
The obsession with heavy schema libraries and complex generic magic often feels like enterprise bureaucracy imported into JavaScript just to make it look 'serious.'
JS powered incredible, complex systems long before TypeScript became the default, yet today people often pull in massive validation frameworks for basic boundaries where simple runtime predicates are more than enough.
I really appreciate that this post advocates for light, composable boundaries instead of adding another heavy dependency just to satisfy the compiler. Sometimes the best TypeScript is the code that stays out of your way and lets JavaScript do its job.
Great article as always!
I agree!
ZodandValibotare powerful, and there are plenty of cases β especially in larger systems β where a schema really is the right abstraction. πΈBut the important question is still "do you actually need a schema here?"
For a small library, subsystem, or narrow runtime boundary, it can easily become too much. Iβve seen code where a schema is introduced purely because someone wants runtime validation, even though thereβs no real need for the schema itself.
I think tools should match the problem.
is how unnecessary complexity and dependency weight slowly become the default, similar to how large utility libraries were often pulled in for things that only needed one or two small helpers.
Sometimes a simple predicate is genuinely the better abstraction. πΈ
The 'honest description' framing actually nails what unknown is supposed to communicate. Most devs reach for
as Userbecause TypeScript stops yelling, but it's basically a pinky promise to the compiler nobody planned to keep.The composable guard pattern you showed is the part that scales. Writing one big isUser that rechecks everything every time is how you end up with validation logic nobody wants to touch six months later.
Quick question on the guard library you linked: how does it handle recursive structures? Like a comment that has child comments. Does it do anything smart there or does it just go as deep as the type definition says?
Thanks! πΈ For recursive structures,
is-kithaslazy().You can define the guard once and let it refer to itself, e.g.
children: arrayOf(isComment). It then validates the actual input recursively as deep as the tree goes, thereβs no fixed depth coming from the type definition.It doesnβt do cycle detection though, so a cyclic in-memory object graph can still recurse until the stack is exhausted. For JSON-style comment trees, it works naturally.
Great read, Nyaomaru! πΈ I like that you showed how far well-designed type guards can take you before introducing a schema library. It's a good reminder that the right level of validation depends on the problem you're solving, not just on what's popular. Thanks for sharing another practical TypeScript article! π
Thank you so much, John! πΈ
Thatβs exactly what I wanted to get across, the right level of validation depends on the problem, not on choosing the most popular tool by default.
I really appreciate you reading and sharing your thoughts as always! π
The detail that safeParse returns the same object is worth carrying into the caller's ownership rules. A successful guard describes that object's shape at that moment; it doesn't stop another reference from mutating it before an awaited operation resumes. I'd test a shared payload changed after validation and decide whether the boundary should copy the fields it needs or keep exclusive ownership. That's separate from the exact option: rejecting extra keys doesn't make the accepted object immutable.
Thatβs a fair point. Iβd consider that a caller-side ownership concern rather than something
safeParseitself should solve.The guard tells you the object is valid at the time of validation, but if that same reference can still be mutated elsewhere, the caller should decide whether to copy what it needs or otherwise control ownership.
And yes,
exactonly affects shape validation, it doesnβt imply immutability. πΈ