How to Validate unknown in TypeScript Without a Schema Library
Keep untrusted values unknown, compose the runtime checks you need, and narrow them with a small tagged result.
The quick answer
Build a reusable object guard, then pass it and the unknown value to safeParse.
import {isString,oneOfValues,safeParse,struct,} from 'is-kit';const isUser = struct({id: isString,name: isString,role: oneOfValues('admin', 'member'),});declare const input: unknown;const result = safeParse(isUser, input);if (result.valid) {result.value.name.toUpperCase();// result.value: Readonly<{// id: string;// name: string;// role: 'admin' | 'member';// }>}
The guard performs the runtime checks. safeParse returns { valid: true, value } or { valid: false }, so TypeScript can narrow the result with an ordinary branch.
There is no generated schema and no hidden conversion. The accepted type is inferred directly from the guards you composed.
Keep boundary values unknown
HTTP responses, parsed JSON, storage values, message payloads, and data from third-party code do not become trustworthy because your application expects a TypeScript type.
Model those values as unknown. Unlike any, it prevents property access until code has established what the value actually is.
unknown is not missing type information. It is an honest description of an unverified boundary value.A type assertion is not validation
The shortest way past unknown is often an as assertion. It is also the easiest way to move the risk somewhere less visible.
type User = {id: string;name: string;};declare const input: unknown;const user = input as User;// No runtime check happened.// This can throw if name is missing or not a string.user.name.toUpperCase();
An assertion changes what the compiler believes. It does not inspect the value, add a property, or turn invalid input into valid data.
A guard connects both sides: it returns a boolean at runtime and narrows the same value when that boolean is true.
Compose the payload you actually accept
Start with primitive guards, then compose literals, optional keys, nullable values, arrays, and nested objects.
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),});isUser({id: 'user-1',role: 'member',tags: ['typescript', 'guards'],}); // trueisUser({id: 'user-1',role: 'owner',tags: ['typescript'],}); // false
Each piece remains a normal predicate. You can test it independently, reuse it at another boundary, or compose it into a larger guard.
optionalKey means the property may be absent. nullable means an existing value may be null. Keeping those meanings separate makes the runtime contract explicit.
Use a direct guard or a tagged result
Guards already work directly in TypeScript control flow. Use safeParse when the validated value needs to move through a result-oriented branch or function boundary.
declare const input: unknown;if (isUser(input)) {renderUser(input);// input is narrowed only inside this branch.}const result = safeParse(isUser, input);if (!result.valid) {return { status: 400 as const };}return {status: 200 as const,user: result.value,};
safeParse does not clone or transform the value. On success, it returns the same value after the guard has accepted it.
Validate JSON at the decode boundary
JSON.parse returns any. safeJsonParse contains that unsafe result as unknown and applies your guard before returning it.
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);// Invalid JSON and guard mismatches both return { valid: false }.// Values are validated as they are; nothing is coerced.
This is decode-then-guard behavior. Invalid JSON and guard failures share the same small failure result, and values are never coerced to satisfy the guard.
Decide whether extra keys are allowed
By default, struct validates declared fields and permits additional keys. Use exact: true when the boundary needs a closed object shape.
import { isString, struct } from 'is-kit';const isCredentials = struct({username: isString,password: isString,},{ exact: true },);isCredentials({ username: 'Neko', password: 'secret' }); // trueisCredentials({username: 'Neko',password: 'secret',admin: true,}); // false
Exact mode rejects extra own enumerable string keys. It follows Object.keys semantics, so symbol properties are outside this check.
Add focused domain rules
Structural checks are often enough. When a field has a small business rule, compose a refinement after its broader guard.
import {and,isString,predicateToRefine,struct,} from 'is-kit';const isNonBlankString = and(isString,predicateToRefine<string>((value) => value.trim().length > 0),);const isMessage = struct({title: isNonBlankString,body: isString,});isMessage({ title: 'Hello', body: '' }); // trueisMessage({ title: ' ', body: 'Hello' }); // false
The string check runs first, so the refinement receives a string rather than unknown. The resulting guard stays reusable and preserves normal TypeScript narrowing.
Choose the smallest sufficient approach
| Approach | Best for | Tradeoff |
|---|---|---|
| Inline typeof checks | One local primitive value | Repeats as shapes grow |
| is-kit guards | Reusable boolean validation | No structured error details |
| Schema library | Rich errors and transformations | Introduces a schema-first workflow |
You are still adding is-kit as a package. The distinction is that it is a zero-runtime-dependency type guard toolkit, not a schema language or validation framework.
When a schema library is the better choice
- You need field paths and multiple structured validation issues.
- You need coercion, defaults, or value transformations.
- A shared schema must generate types or external artifacts.
- Your forms or API framework integrate with a schema ecosystem.
is-kit guards answer a narrower question: does this value satisfy the predicate, and if so, what can TypeScript safely narrow it to?
Summary
- Keep unverified boundary values typed as unknown.
- Use guards instead of assertions when runtime trust matters.
- Compose small predicates into the payload shape you accept.
- Use
safeParsefor a tagged result andsafeJsonParseat JSON text boundaries. - Adopt a schema library when richer validation is the requirement.
Continue with the parse and struct API references for the complete contracts.