TypeScript can make a React Native codebase safer.
It can catch missing properties, incorrect function arguments, invalid component props and many other mistakes before the application runs.
But TypeScript has one important limitation:
It cannot verify whether external data actually matches the type you assigned to it.
This becomes especially important in React Native because application data often crosses multiple runtime boundaries.
A value may come from:
- a native Android module
- an iOS native module
- an API response
- persisted storage
- a deep link
- remote configuration
- an analytics SDK
- an AI-generated JSON response
At each of these boundaries, the actual runtime value may differ from the TypeScript definition.
And TypeScript may not warn you.
A simple example
Assume a React Native application receives battery information from a native module.
type BatteryStatus = {
level: number;
isCharging: boolean;
};
The JavaScript layer expects a value like this:
const batteryStatus: BatteryStatus = {
level: 82,
isCharging: true,
};
This looks safe.
The level field is a number.
The isCharging field is a boolean.
Now imagine that the Android implementation changes and returns this instead:
{
level: "82",
isCharging: true
}
The level value is now a string.
At runtime, JavaScript receives:
typeof batteryStatus.level === "string";
But TypeScript may still believe that level is a number.
How can that happen?
Because the result may have been cast.
const batteryStatus =
NativeModules.BatteryModule.getStatus() as BatteryStatus;
The cast tells TypeScript:
Trust me. This value is a
BatteryStatus.
TypeScript accepts that instruction.
It does not inspect the Android or iOS runtime value.
Casting is not validation
This distinction is important.
A type cast changes what TypeScript believes.
It does not change or verify the actual value.
Consider this example:
const value = "82" as unknown as number;
TypeScript may allow the code.
But the runtime value is still a string.
console.log(typeof value);
// string
This is why the following assumption is dangerous:
const response = externalValue as ExpectedType;
The code may compile successfully while the application remains unsafe.
A better mental model is:
Casting changes the compiler's understanding. Validation checks reality.
Where the failure appears
Boundary problems do not always fail immediately.
That is one reason they can be difficult to diagnose.
The application may receive the incorrect value on one screen, store it in state and fail much later.
For example:
const nextLevel = batteryStatus.level + 10;
If level is a number:
82 + 10;
// 92
If level is a string:
"82" + 10;
// "8210"
The app may not crash.
It may simply produce the wrong result.
That is often worse because the problem can remain hidden.
The value may also fail during:
- numeric comparisons
- sorting
- formatting
- progress calculations
- conditional rendering
- database queries
- analytics processing
- business-rule evaluation
For example:
if (batteryStatus.level < 20) {
showLowBatteryWarning();
}
JavaScript may coerce the value and produce a result.
But relying on implicit coercion makes application behaviour fragile.
Compile-time trust should stop at runtime boundaries
Inside a properly typed TypeScript system, values can usually be trusted.
For example:
function showBatteryStatus(status: BatteryStatus) {
console.log(status.level);
}
If every caller passes a real BatteryStatus, the function is safe.
The problem begins when a value enters the system from outside.
That value has not been created or verified by TypeScript.
Therefore, the safer rule is:
Treat external values as
unknownuntil they have been validated.
Instead of this:
const status =
NativeModules.BatteryModule.getStatus() as BatteryStatus;
Prefer this:
const status: unknown =
await NativeModules.BatteryModule.getStatus();
Now the application is forced to prove that the value is safe before using it.
Why unknown is better than any
Many boundary values are typed as any.
const response: any = await fetchBatteryStatus();
This removes most type protection.
You can access anything:
response.level;
response.invalidProperty;
response.anythingAtAll;
TypeScript will not stop you.
unknown behaves differently.
const response: unknown = await fetchBatteryStatus();
You cannot safely access its properties until you narrow the type.
response.level;
// TypeScript error
This may initially feel inconvenient.
But that inconvenience is useful.
It forces the code to validate the runtime value.
Manual runtime validation
A simple type guard can validate the native response.
type BatteryStatus = {
level: number;
isCharging: boolean;
};
function isBatteryStatus(value: unknown): value is BatteryStatus {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.level === "number" &&
typeof candidate.isCharging === "boolean"
);
}
Now the boundary can be handled safely.
const rawStatus: unknown =
await NativeModules.BatteryModule.getStatus();
if (!isBatteryStatus(rawStatus)) {
throw new Error("Invalid battery status returned by native module");
}
const status: BatteryStatus = rawStatus;
console.log(status.level);
After the validation succeeds, TypeScript knows that rawStatus is a valid BatteryStatus.
This is a safe conversion from an untrusted value into a trusted application type.
Validate business rules, not only field types
Checking whether level is a number may not be enough.
The value may still be invalid.
{
level: 400,
isCharging: true
}
The shape is correct.
But a battery percentage of 400 is not valid.
A stronger validator should also verify allowed ranges.
function isBatteryStatus(value: unknown): value is BatteryStatus {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.level === "number" &&
candidate.level >= 0 &&
candidate.level <= 100 &&
typeof candidate.isCharging === "boolean"
);
}
Runtime validation may need to check:
- required fields
- primitive types
- allowed ranges
- enum values
- string formats
- nested object shapes
- array item types
- mutually exclusive states
- domain-specific rules
A structurally correct object can still be invalid for the application.
Using a schema validation library
Manual validation works well for small objects.
For larger payloads, a schema library can make the code easier to maintain.
A common approach is to define a schema and derive the TypeScript type from it.
For example, using Zod:
import { z } from "zod";
const BatteryStatusSchema = z.object({
level: z.number().min(0).max(100),
isCharging: z.boolean(),
});
type BatteryStatus = z.infer<typeof BatteryStatusSchema>;
The external value can then be parsed.
const rawStatus: unknown =
await NativeModules.BatteryModule.getStatus();
const result = BatteryStatusSchema.safeParse(rawStatus);
if (!result.success) {
console.error(result.error);
throw new Error("Invalid battery status");
}
const status = result.data;
The important part is not the specific library.
The important part is the boundary discipline:
- Receive untrusted data.
- Validate it.
- Convert it into a trusted type.
- Use it inside the application.
Runtime boundaries in React Native
React Native applications contain more boundaries than many teams realise.
1. Native modules
Native modules may return different types across Android and iOS.
For example:
- Android returns an integer
- iOS returns a floating-point number
- one platform returns
null - the other platform omits the field
- a native SDK returns a string representation
Generated TurboModule specifications can improve contract safety, but runtime validation may still be useful when data originates from external native SDKs, persisted native state or platform APIs.
2. Network responses
An API client may declare a generic response type:
const response = await api.get<User>("/user");
This does not guarantee that the server returned a valid User.
It only tells TypeScript how the code intends to use the response.
The backend may return:
{
"id": "123",
"age": "42"
}
while the application expects:
type User = {
id: string;
age: number;
};
API types are expectations until runtime validation confirms them.
3. Persisted storage
Data stored in AsyncStorage, MMKV, SQLite or another persistence layer can become stale.
The application model may change between releases.
An older app version may have saved:
{
"theme": "dark"
}
A newer version may expect:
type Settings = {
theme: "light" | "dark";
fontScale: number;
};
Reading stored JSON and casting it directly can create hidden problems.
const settings =
JSON.parse(storedValue) as Settings;
Parsing confirms only that the value is valid JSON.
It does not confirm that it matches Settings.
4. Deep links
Deep-link parameters are external input.
A link may contain missing, malformed or unexpected values.
myapp://payment?amount=abc
The application should not assume that amount is a valid number merely because the navigation type expects one.
5. Remote configuration
Remote configuration can change without an application release.
A field that was previously boolean may accidentally be configured as a string.
{
"newCheckoutEnabled": "true"
}
Without validation, the application may treat a non-empty string as truthy and enable behaviour unexpectedly.
6. AI-generated structured output
AI-generated JSON should always be treated as untrusted.
Even when a model is instructed to follow a specific schema, it may:
- omit required fields
- return additional fields
- use incorrect enum values
- produce malformed JSON
- return numbers as strings
- generate semantically invalid combinations
For example:
{
"priority": "extremely_urgent",
"retryCount": "-3"
}
The response may look structured while still violating the application contract.
AI output requires both structural validation and business-rule validation.
Validate once at the edge
Runtime validation should not be scattered throughout the entire application.
That creates repetitive and inconsistent checks.
A stronger architecture validates values at the edge.
async function getBatteryStatus(): Promise<BatteryStatus> {
const rawStatus: unknown =
await NativeModules.BatteryModule.getStatus();
return BatteryStatusSchema.parse(rawStatus);
}
The rest of the application can now depend on the trusted function.
const status = await getBatteryStatus();
renderBatteryLevel(status.level);
This gives the codebase a clear separation:
Untrusted external data
↓
Boundary validation
↓
Trusted application model
↓
Business logic and UI
The validation cost is paid once.
The trusted type can then flow safely through the internal system.
Parse, do not merely check
In some cases, the application may intentionally transform external data.
Suppose the native module returns:
{
level: "82",
isCharging: true
}
The application may decide that numeric strings are acceptable.
Instead of rejecting the value, the boundary parser can normalise it.
function parseBatteryStatus(value: unknown): BatteryStatus {
if (typeof value !== "object" || value === null) {
throw new Error("Battery status must be an object");
}
const candidate = value as Record<string, unknown>;
const level =
typeof candidate.level === "string"
? Number(candidate.level)
: candidate.level;
if (
typeof level !== "number" ||
Number.isNaN(level) ||
level < 0 ||
level > 100
) {
throw new Error("Invalid battery level");
}
if (typeof candidate.isCharging !== "boolean") {
throw new Error("Invalid charging state");
}
return {
level,
isCharging: candidate.isCharging,
};
}
This approach is often called parsing rather than validation.
Validation asks:
Is this value already valid?
Parsing asks:
Can this value be safely converted into the trusted form?
Both approaches are useful.
The choice should be explicit.
A practical code-review checklist
Whenever a value enters a React Native application, ask:
- Where did this value come from?
- Is the source controlled by the TypeScript compiler?
- Is the value typed as
anyor cast usingas? - What happens if a field is missing?
- What happens if a number arrives as a string?
- Are null and undefined handled differently?
- Are allowed ranges and enum values checked?
- Is validation performed once at the boundary?
- Is the failure logged with enough context?
- Can the application recover safely from invalid data?
These questions are especially important during:
- native-module reviews
- API integration
- storage migrations
- SDK upgrades
- backend contract changes
- AI feature development
- platform-specific debugging
Final takeaway
TypeScript is excellent at preserving correctness inside a typed system.
But it does not control the values coming from outside that system.
A type definition describes what the application expects.
A type cast tells the compiler what to believe.
Runtime validation checks what the application actually received.
The engineering rule is simple:
Treat external values as
unknown, validate them at the boundary and only then convert them into trusted application types.
Because in production React Native applications:
Casting is not validation.
Which runtime boundary has caused the most unexpected type failure in your React Native application?
Top comments (1)
How do you handle type safety when integrating third-party native modules in React Native, and do you have any go-to strategies for ensuring type consistency across the boundary? I'd love to swap ideas on this.