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 (2)
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!
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! π