How to Keep Hand-Written Type Guards in Sync with TypeScript Types
Start with the type you already have, write the runtime checks by hand, and make structural drift a compile-time error.
The quick answer
Pass your existing object type to typedStruct, then define one guard for every string-keyed field.
import {isNumber,isString,optionalKey,typedStruct,} from 'is-kit';type User = {id: string;name: string;age?: number;};const isUser = typedStruct<User>()({id: isString,name: isString,age: optionalKey(isNumber),});declare const input: unknown;if (isUser(input)) {input.name.toUpperCase();// input: Readonly<User>}
At runtime, this uses the same object validation as struct. At compile time, the field map is checked against User for missing, extra, and incompatible string-keyed fields.
The guards are still explicit. The useful part is that the compiler now knows which type they are supposed to follow.
Why a type predicate can drift silently
A hand-written predicate often starts small. Then the application type gains a field, while the runtime check stays unchanged.
type User = {id: string;name: string;role: 'admin' | 'member';};const isUser = (value: unknown): value is User => {if (typeof value !== 'object' || value === null) return false;const candidate = value as Record<string, unknown>;return (typeof candidate.id === 'string' &&typeof candidate.name === 'string');};// The implementation forgot role, but the annotation still compiles.// TypeScript trusts a user-defined type predicate.
This is valid TypeScript. A return type such as value is User is a promise made by your function, not a proof derived from its body.
The compiler can check a guard’s declared type. It cannot prove that arbitrary runtime logic checks every field.
Make string-keyed structural drift visible
For ordinary string-keyed object shapes, typedStruct<User>() turns the object type into a compile-time contract for the field map.
import {isNumber,isString,oneOfValues,optionalKey,typedStruct,} from 'is-kit';type User = {id: string;name: string;role: 'admin' | 'member';age?: number;};typedStruct<User>()({id: isString,name: isString,age: optionalKey(isNumber),// TypeScript error: role is missing.});typedStruct<User>()({id: isString,name: isNumber,// TypeScript error: name requires a string-compatible guard.role: oneOfValues('admin', 'member'),age: optionalKey(isNumber),});
The same check also rejects string-keyed schema fields that do not exist on User. String-keyed renames, additions, removals, and field-type changes therefore surface next to the guard definition during type checking.
This does not remove maintenance. It moves forgotten maintenance from production behavior into a compiler error.
This drift guarantee is limited to string-keyed properties. Numeric and symbol properties are excluded fromTypedStructShape, so they are not required in the field map and cannot be validated by the resultingtypedStructguard.
Keep optional keys explicit
Optional string-keyed object properties must still appear in the field map. Wrap them with optionalKey so the key may be absent at runtime.
import {isString,nullable,optionalKey,typedStruct,} from 'is-kit';type User = {id: string;nickname?: string | null;};const isUser = typedStruct<User>()({id: isString,nickname: optionalKey(nullable(isString)),});isUser({ id: 'user-1' }); // trueisUser({ id: 'user-1', nickname: null }); // trueisUser({ id: 'user-1', nickname: 'Neko' }); // trueisUser({ id: 'user-1', nickname: 42 }); // false
Key optionality and value nullability are separate decisions. Here,optionalKey allows nickname to be absent, while nullable allows an existing key to contain null.
Requiring optional keys in the field map is deliberate. If a new optional string-keyed property is added to User, the guard should not ignore it silently.
Compose nested existing types
For nested objects, define a focused guard from the corresponding property type and compose it into the parent guard.
import {arrayOf,isString,nullable,typedStruct,} from 'is-kit';type Account = {readonly id: string;readonly profile: {readonly displayName: string;readonly bio: string | null;} | null;readonly tags: readonly string[];};const isProfile = typedStruct<NonNullable<Account['profile']>>()({displayName: isString,bio: nullable(isString),});const isAccount = typedStruct<Account>()({id: isString,profile: nullable(isProfile),tags: arrayOf(isString),});
Referencing Account['profile'] keeps the nested guard connected to the original type without copying the object shape into another TypeScript alias.
Reuse the existing type at compile time. Compose small guards at runtime.
Compile-time fields and runtime extra keys
typedStruct rejects extra string-keyed fields in the guard definition. Extra keys in an input object are a separate runtime choice.
import { isString, typedStruct } from 'is-kit';type User = {id: string;name: string;};const isExactUser = typedStruct<User>()({id: isString,name: isString,},{ exact: true },);isExactUser({ id: 'user-1', name: 'Ada' }); // trueisExactUser({ id: 'user-1', name: 'Ada', debug: true }); // false
Without { exact: true }, matching objects may contain additional own enumerable string keys. Enable it when the runtime boundary requires a closed object shape.
Choose the right source of truth
| Approach | Source of truth | Best for |
|---|---|---|
| Manual predicate | Your implementation | Custom, non-structural logic |
| struct | The guard field map | Guard-first object types |
| typedStruct | An existing TypeScript type | Type-first application code |
| Schema library or codegen | A schema or generated artifact | Rich errors, transforms, or generation |
Use struct when the guard should define the resulting type. Use typedStruct when the TypeScript type already exists and the hand-written guard must follow it.
What typedStruct does not do
- It does not generate runtime validation from erased types.
- It does not track or validate numeric and symbol properties on the target type.
- It cannot prove that a custom predicate’s implementation is honest.
- It does not coerce data or return structured validation errors.
- It does not replace a schema-first workflow when a schema is your actual source of truth.
It is intentionally smaller: a typed bridge between an existing object type and the guards you choose to run.
Summary
- Use
typedStruct<T>()when a string-keyed object typeTalready exists and needs a runtime guard. - Declare required and optional string keys so type drift stays visible.
- Compose nested guards from the corresponding property types.
- Use
exact: trueonly when runtime inputs must reject additional keys.
For the complete contract and additional examples, see the typedStruct API reference.