Hoi hoi!
I'm @nyaomaru, a frontend engineer who recently obsessed with playing video game of "inscryption". π
In TypeScript, there are several syntaxes that look like they are "specifying a type."
For example:
assatisfies- generics using
T extends xxx
But these are not the same thing.
Roughly speaking:
-
asasks TypeScript to trust you -
satisfiesasks TypeScript to check the value - generics let you encapsulate that check inside an API
They all look type-related, but the responsibility lives in different places.
You may see AI-generated code overusing as, or codebases using satisfies casually, or libraries using generics to enforce stricter APIs. These patterns appear quite often in real-world development.
In this article, I want to explain the difference between as, satisfies, and generics not as "ways to specify a type," but as ways to decide where type responsibility should live.
Let's jump in.
π as asks TypeScript to trust you
In TypeScript, as lets you tell the compiler:
Please treat this value as this type, please π
For example, the runtime value below is still a string, but TypeScript treats it as a number.
const value = "123" as unknown as number;
console.log(typeof value); // "string"
value.toFixed();
// TypeScript thinks this is a number, so there is no compile-time error.
// But at runtime, this throws: "value.toFixed is not a function"
The important point is π
as does not transform the runtime value.
So using it for external data can be very dangerous.
type User = {
id: string;
name: string;
};
const user = JSON.parse(input) as User;
This does not check whether the parsed value is actually a User.
It only tells TypeScript:
Trust me. This is a
User. Maybe.
In other words, as is for cases where a human knows something TypeScript cannot know.
It is useful, but it does not guarantee anything by itself.
If you write as, the responsibility is on you.
π satisfies asks TypeScript to check the value
Now let's look at satisfies, which was introduced in TypeScript 4.9.
type RouteConfig = Record<
string,
{
path: string;
label: string;
}
>;
const routes = {
home: {
path: "/",
label: "Home",
},
about: {
path: "/about",
label: "About",
},
} satisfies RouteConfig;
With this, TypeScript checks whether the declared value matches the expected type structure.
For example, if you add a property that does not exist on a route item, TypeScript reports an error.
const routes = {
home: {
path: "/",
label: "Home",
},
about: {
path: "/about",
label: "About",
nyaomaru: "cat", // Type error: nyaomaru does not exist on the route item type πΌ
},
} satisfies RouteConfig;
The interesting part is π
Even though routes is checked against RouteConfig, the type of routes itself is not collapsed into RouteConfig.
routes is checked as RouteConfig.
But the inferred type of routes remains close to the actual object literal you wrote.
So keyof typeof routes becomes the union of the actual keys, not just string.
type RouteName = keyof typeof routes;
// "home" | "about"
In other words, satisfies checks that a value satisfies a given type while preserving the original inference as much as possible.
Compare that with a regular type annotation.
const routes: RouteConfig = {
home: {
path: "/",
label: "Home",
},
about: {
path: "/about",
label: "About",
},
};
type RouteName = keyof typeof routes;
// string
When you annotate routes as RouteConfig, TypeScript treats it as RouteConfig.
Since RouteConfig is a Record<string, ...>, RouteName becomes string.
So when you want to validate a value against a type while keeping the concrete information from the actual value, satisfies is often a better fit.
In short:
satisfies checks that a value matches a type without unnecessarily widening the value's inferred type.
π Generics encapsulate the check inside an API
At this point, you might think:
Okay, then I should just use
satisfieseverywhere. πΉ
And yes, sometimes that works well.
But writing satisfies every time can be annoying.
And if someone forgets to write it, the intended check disappears.
So what if we move that check into a function?
That is where generics come in.
As a quick recap, we could write this
const routes = {
home: {
path: "/",
label: "Home",
},
} satisfies RouteConfig;
But we can also wrap the pattern inside a function using generics.
function defineRoutes<const T extends RouteConfig>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: {
path: "/",
label: "Home",
},
});
Nice and clean.
Now we get:
- validation that the value matches
RouteConfig - preservation of the concrete key
"home" - no need for users to write
satisfiesevery time
But then this appears
<const T extends RouteConfig>
What is going on here???
Let's break it down.
-
T extends RouteConfigmeans: "Tmust be assignable toRouteConfig." -
const Tasks TypeScript to infer object literals and array literals as narrowly as possible.
Here, const is not the same as a const variable declaration.
It is not about whether a value can be reassigned.
It is about making generic type parameter inference preserve literal information as much as possible.
π Comparing the three with config objects
Here is the comparison so far.
| Syntax | What it does | Main responsibility |
|---|---|---|
as Type |
Asks TypeScript to believe the value is that type | The person writing the code |
satisfies Type |
Checks whether the value matches the type | The value declaration |
<T extends Type> |
Constrains what an API can accept | The API designer |
So, in casual terms:
-
asmeans: "Trust me. π" -
satisfiesmeans: "Please check if this is correct. π" - generics mean: "Only pass values that fit this API. π"
So far, we have looked at config objects.
But in real-world development, this difference becomes even more important when dealing with external data.
API responses, localStorage, URL params, and form input cannot be protected by TypeScript types alone.
For those values, we need runtime guards.
And once we start writing runtime guards by hand, another problem appears π
The existing TypeScript type and the handwritten schema can drift apart.
π€ Thinking through typedStruct
This becomes easier to understand if we use a small validator API as an example.
I'll use my type guard library, is-kit.
nyaomaru
/
is-kit
Lightweight, zero-dependency toolkit for building `isFoo` style type guards in TypeScript. Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨. npm -> https://www.npmjs.com/package/is-kit
is-kit
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
It helps you write small isFoo functions, compose them into richer runtime checks, and keep TypeScript narrowing natural inside regular control flow.
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 - Validate object shapes and collections
-
Parse or assert
unknownvalues without a large schema framework
Best for app-internal narrowing, filtering, and reusable guards.
π€ Why use is-kit?
Tired of rewriting the same isFoo checks again and again?
is-kit is a good fit when you want to:
-
write reusable
isXfunctions instead of one-off inline checks - keep runtime validation lightweight and dependency-free
-
narrow values directly in
if,filter, and other TypeScript control flow - compose validation logicβ¦
is-kit is a lightweight library for building small type guards like isString and isNumber, then composing them with helpers like and, or, and struct, while still allowing TypeScript narrowing to work naturally.
Suppose an external API returns a User like this
type User = {
id: string;
name: string;
};
With a normal struct, we can create a guard for that object shape.
const isUser = struct({
id: isString,
name: isString,
});
This is useful.
struct can infer the return type from the schema, so isUser can validate a value that looks like a User.
But there is one problem.
It is easy for the schema and the existing type to drift apart.
For example, suppose the User type already exists, but we accidentally write the wrong key in the schema.
type User = {
id: string;
name: string;
};
const isUser = struct({
id: isString,
displayName: isString,
});
This struct call itself is valid.
From the perspective of struct, this is a perfectly valid schema for:
{
id: string;
displayName: string;
}
So struct is useful for creating a type from a schema.
But it does not synchronize that schema with an existing User type.
A common dangerous workaround is to use as.
const isUser = struct({
id: isString,
displayName: isString,
}) as Predicate<User>;
This is extremely dangerous. β οΈβ οΈβ οΈ
The schema says displayName, but we are telling TypeScript
Trust me. This is a guard for
User. Please π
That as is not a check.
It is just a self-declaration.
Now, satisfies can improve this.
If the library exposes a schema shape type, we could write something like:
const userSchema = {
id: isString,
name: isString,
} satisfies TypedStructShape<User>;
Now TypeScript checks whether the schema corresponds to User.
If we write an unknown key or forget a required key, TypeScript can report an error.
const userSchema = {
id: isString,
displayName: isString,
// name is missing, and displayName does not exist on User
} satisfies TypedStructShape<User>;
This is better.
But writing this every time is a bit annoying.
Also, if the user forgets to write satisfies TypedStructShape<User>, the schema and the type can drift apart again.
So we can use generics to encapsulate this check inside the API.
const isUser = typedStruct<User>()({
id: isString,
name: isString,
});
With this shape, users do not need to write satisfies TypedStructShape<User> every time.
Instead, the typedStruct<User>() API itself checks π
Does this schema correspond to
User?
So, using the framing from earlier:
- we are not using
asto ask TypeScript to trust us - we are not asking users to write
satisfiesevery time - we are using generics to encapsulate the check inside an API
That is the core idea.
Let's look a little more concretely.
The implementation idea for typedStruct looks like this:
type OptionalKeys<T> = {
// WHY: Optional keys accept an empty object when picked in isolation.
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
type TypedStructShape<T extends object> = {
readonly [K in Extract<RequiredKeys<T>, string>]-?: Predicate<T[K]>;
} & {
readonly [K in Extract<OptionalKeys<T>, string>]-?: OptionalSchemaField<
Predicate<T[K]>
>;
};
type NoExtraKeys<S, Shape> = S & {
readonly [K in Exclude<keyof S, keyof Shape>]: never;
};
type TypedStructFields<
T extends object,
S extends TypedStructShape<T>,
> = NoExtraKeys<S, TypedStructShape<T>>;
export function typedStruct<T extends object>() {
return <const S extends TypedStructShape<T>>(
fields: TypedStructFields<T, S>,
options?: { exact?: boolean },
): Predicate<InferSchema<TypedStructFields<T, S>>> =>
// WHY: `typedStruct` keeps `struct` runtime behavior while using the target
// type only to check that hand-written guard fields stay in sync.
struct(fields, options);
}
We can ignore the small details for now.
The important part is this:
typedStruct<T extends object>()
First, typedStruct receives the target type T.
Then this part:
<const S extends TypedStructShape<T>>
means the actual schema S passed by the user must correspond to T.
But here is an important detail
S extends TypedStructShape<T> alone cannot completely prevent extra keys.
TypeScript uses structural typing.
If a type has the required properties, it may still satisfy a constraint even if it also has additional properties.
For example, if User is { id: string; name: string }, then a schema like { id, name, displayName } has all required keys and one extra key.
The constraint S extends TypedStructShape<User> alone may still allow that.
So we use NoExtraKeys to make keys that do not exist in TypedStructShape<T> become never.
Now let's look at how TypedStructShape<T> works.
It separates required keys and optional keys from T.
type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;
OptionalKeys<T> is used to pick optional properties like name?: string.
Then TypedStructShape<T> treats the keys differently:
- required keys become
Predicate<T[K]> - optional keys become
OptionalSchemaField<Predicate<T[K]>>
That means if a key is optional in the target type, the schema must also mark it as optional using something like optionalKey(...).
type TypedStructShape<T extends object> = {
readonly [K in Extract<RequiredKeys<T>, string>]-?: Predicate<T[K]>;
} & {
readonly [K in Extract<OptionalKeys<T>, string>]-?: OptionalSchemaField<
Predicate<T[K]>
>;
};
Here, OptionalSchemaField is the internal type used to represent a schema field created by a helper like optionalKey(...).
Next, TypedStructFields<T, S> prevents schema fields from having keys that do not exist in T.
Roughly speaking, NoExtraKeys takes keys that exist in S but not in Shape, and turns them into never.
For example, if User does not have displayName, but the schema includes displayName, that field becomes never.
Since a normal guard cannot be assigned to never, TypeScript rejects the extra key.
type NoExtraKeys<S, Shape> = S & {
readonly [K in Exclude<keyof S, keyof Shape>]: never;
};
type TypedStructFields<
T extends object,
S extends TypedStructShape<T>,
> = NoExtraKeys<S, TypedStructShape<T>>;
So typedStruct is not just a helper that adds a type to struct.
It is an API for making an existing object type and a handwritten runtime guard harder to drift apart.
One important point π
typedStruct does not automatically generate a runtime validator.
TypeScript types do not exist at runtime.
Also, we cannot automatically generate isString or isNumber from the User type.
So the guard itself still has to be written by hand.
const isUser = typedStruct<User>()({
id: isString,
name: isString,
});
But the API can check whether the handwritten schema is still in sync with User.
It's the tasty part of typedStruct.
This is especially useful when you already have API response types generated from something like OpenAPI.
Here, imagine ApiResponse is a generated response type.
type ArticleResponse = ApiResponse<"/articles/{id}", "get">;
const isArticleResponse = typedStruct<ArticleResponse>()({
id: isNumber,
title: isString,
summary: optionalKey(isString),
});
Here, optionalKey(isString) means the object key itself may be missing.
But if the key exists, the value must pass isString.
So as a runtime check, this does not allow { summary: undefined }.
It means:
- if
summaryexists, it must be astring - if
summarydoes not exist, that is OK
This is a small but important distinction.
The idea is not β
The TypeScript property is optional, so I can omit the schema field.
Instead, the idea is β
The runtime guard should explicitly say that this key is optional.
That way, when the target type changes, the mismatch becomes easier to notice.
Also, the compile-time key check in typedStruct and the runtime check in struct(..., { exact: true }) are different things.
const isUser = typedStruct<User>()(
{
id: isString,
name: isString,
},
{ exact: true },
);
NoExtraKeys is a compile-time check that prevents the schema author from writing extra keys in the schema.
On the other hand, { exact: true } is a runtime check that rejects actual runtime objects if they contain keys outside the schema.
They may look similar, but they check different things.
-
NoExtraKeyschecks mistakes in the handwritten schema -
{ exact: true }checks extra keys in runtime data
This is exactly the theme of the article.
Instead of silencing TypeScript with as, we take a satisfies-like check and encapsulate it inside an API with generics.
One more important design point π
The return type is not directly forced to
T.
Instead, it is inferred from the actual fields passed to the function.
Predicate<InferSchema<TypedStructFields<T, S>>>;
So T is used to check whether the schema is in sync with the existing type.
But the actual guard type is inferred from the schema, just like struct.
In other words, we are not doing this
as Predicate<User>
Instead, the existing type synchronization is handled by the generic constraint, while the actual guard type is derived from the schema.
π― Summary
In this article, we looked at the difference between as, satisfies, and generics through the lens of where type responsibility lives.
| Syntax | Role | Where the responsibility lives |
|---|---|---|
as Type |
Asks TypeScript to trust you π | The person writing the code |
satisfies Type |
Checks whether a value matches a type π | The value declaration |
<T extends Type> |
Constrains what an API accepts π | The API designer |
as is not evil.
Sometimes TypeScript cannot know something, and a human has to take responsibility for that missing information.
But as is not a check.
When you use as for external data or handwritten schemas, the code can look type-safe while skipping the actual validation.
AI-generated code often papers over uncertainty with as, so this is something worth watching for.
When you really want to check whether a value has the right shape, first consider whether satisfies can help.
And when you find yourself repeating the same satisfies pattern again and again, consider moving that constraint into an API with generics.
Personally, I find this framing useful:
-
asmeans: "Trust me." -
satisfiesmeans: "Please check if this is correct." - generics mean: "Only pass values that fit this API."
typedStruct in is-kit is a good example of this difference.
When you want to keep an existing object type and a runtime guard in sync, do not silence TypeScript with as.
Instead, you can take a satisfies-like check and encapsulate it inside an API using generics.
This makes the safer way the natural way to use the API, and moves type responsibility closer to the API design.
Happy TypeScript life. π«°
Bonus
The user-defined type guard library used in this article is is-kit.
nyaomaru
/
is-kit
Lightweight, zero-dependency toolkit for building `isFoo` style type guards in TypeScript. Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨. npm -> https://www.npmjs.com/package/is-kit
is-kit
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
It helps you write small isFoo functions, compose them into richer runtime checks, and keep TypeScript narrowing natural inside regular control flow.
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 - Validate object shapes and collections
-
Parse or assert
unknownvalues without a large schema framework
Best for app-internal narrowing, filtering, and reusable guards.
π€ Why use is-kit?
Tired of rewriting the same isFoo checks again and again?
is-kit is a good fit when you want to:
-
write reusable
isXfunctions instead of one-off inline checks - keep runtime validation lightweight and dependency-free
-
narrow values directly in
if,filter, and other TypeScript control flow - compose validation logicβ¦
typedStruct was added in v1.8, so please give it a try. π»




Top comments (0)