Typing Styles, Routes and Responses
StyleProp and ViewStyle for a style prop, generics for route params, and a type guard instead of casting a response to any.
style?: StyleProp<ViewStyle>Three things hand you a value whose shape the compiler cannot guess: a style passed in from outside, a route parameter, and a parsed response body. Each has a type that fits it.
Styles
View's style prop is StyleProp<ViewStyle>. Text uses TextStyle, Image uses ImageStyle, and all of them are importable from react-native. StyleProp<T> accepts a single style object, a StyleSheet.create entry, an array of either, or false, null and undefined - which is why style={[styles.base, isActive && styles.active]} type-checks. Declare style?: StyleProp<ViewStyle> on your own component to let callers adjust it. This resembles CSS and is not: the keys are a closed list, so a misspelling is a compile error rather than a silently ignored declaration, and nothing cascades in from a parent.
Route parameters
With "experiments": { "typedRoutes": true } in app.json, Expo CLI generates types from the files under app/. useLocalSearchParams<'/user/[id]'>() then returns { id: string }. Query values that no file declares get typed by hand: useLocalSearchParams<{ query?: string }>(). Stage 4 covers the navigation itself.
Responses
Parsed JSON is any. Writing as User changes what the compiler believes and checks nothing. Type the value unknown and narrow it with a guard - function isUser(value: unknown): value is User - so one function does the runtime check and the typing together.
Example
// src/components/Badge.tsx
import {
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
type BadgeProps = {
label: string;
// StyleProp<T> takes one object, a StyleSheet entry, an array of
// either, or false/null/undefined - which is what makes the
// conditional array below legal. The keys are a closed set, so
// 'backgrounColor' is a compile error, not a no-op.
style?: StyleProp<ViewStyle>;
labelStyle?: StyleProp<TextStyle>;
tone?: 'neutral' | 'warning';
};
export default function Badge({
label,
style,
labelStyle,
tone = 'neutral',
}: BadgeProps) {
return (
// Later entries win. The caller's style comes last on purpose.
<View style={[styles.badge, tone === 'warning' && styles.warning, style]}>
<Text style={[styles.label, labelStyle]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#e6e8eb',
},
warning: { backgroundColor: '#ffd8a8' },
label: { fontSize: 12, fontWeight: '600', color: '#1b1f23' },
});When to use it
- Let a design-system component accept a style prop from its caller without using any.
- Catch a misspelled style key before the app reloads instead of wondering why nothing changed.
- Read an id out of a dynamic route and know it is a string that needs converting.
- Validate data from outside the app once, in a guard, instead of casting it at every use.
More examples
Narrowing unknown data instead of casting it
A plain function, no UI. The return type `value is User` is a type predicate: inside the true branch the compiler treats raw as a User, and it earned that by checking three fields. The one internal cast, to Record<string, unknown>, only makes property access legal - it asserts nothing about the values. In Stage 5 the same guard wraps the result of a network call, whose json() is typed as any; here it is enough that any outside data, including something read back from storage, deserves the same treatment.
// src/api/user.ts
export type User = { id: string; name: string; admin: boolean };
// 'unknown' is the honest type for data you did not create. This guard
// returns a boolean at runtime AND narrows the type for the compiler.
// 'as User' would do only the second, and lie if the data differed.
export function 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' &&
typeof candidate.admin === 'boolean'
);
}
const raw: unknown = JSON.parse('{"id":"7","name":"Ada","admin":true}');
console.log(isUser(raw) ? raw.name : 'not a user');
// Output: Ada
// id is a number here, so the shape does not match
const broken: unknown = JSON.parse('{"id":7,"name":"Ada","admin":true}');
console.log(isUser(broken) ? broken.name : 'not a user');
// Output: not a userA typed route parameter
Two lines of text on a screen reached at /user/7. The file name is the route, and the square brackets make id dynamic. Pass the full route as the generic and the parameter names are checked against the file system, so a typo in the route string or in id fails to compile. For a query value that no file declares, pass an object type instead: useLocalSearchParams<{ query?: string }>(). Stage 4 is where routes, links and navigators are actually taught.
// src/app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function UserScreen() {
// With experiments.typedRoutes on, Expo CLI generates types from the
// files in app/, so the route string itself types the params:
// id is string, not string | undefined.
const { id } = useLocalSearchParams<'/user/[id]'>();
// Params arrive as strings whatever the type says they are called.
const numericId = Number(id);
return (
<View style={styles.screen}>
<Text style={styles.line}>User {id}</Text>
<Text style={styles.line}>Next id: {numericId + 1}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, gap: 8 },
line: { fontSize: 16 },
});A function that returns a style
A tappable row that gains a pale background and a blue left bar when selected. The static parts stay in StyleSheet.create; only the values that depend on state are computed, and annotating the helper's return as ViewStyle means an invalid key is caught in the helper rather than at the call site. Reach for this when a style depends on props or state - a selected row, a progress width, a theme colour.
// src/components/Row.tsx
import { Pressable, StyleSheet, Text, type ViewStyle } from 'react-native';
// Returning ViewStyle, not StyleProp<ViewStyle>, keeps the result a
// single plain object - easier for a caller to merge or inspect.
function rowStyle(selected: boolean): ViewStyle {
return {
backgroundColor: selected ? '#dbeafe' : 'transparent',
borderLeftWidth: selected ? 3 : 0,
borderLeftColor: '#1f6feb',
};
}
type RowProps = {
label: string;
selected: boolean;
onPress: () => void;
};
export default function Row({ label, selected, onPress }: RowProps) {
return (
<Pressable onPress={onPress} style={[styles.base, rowStyle(selected)]}>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
base: { paddingVertical: 12, paddingHorizontal: 16 },
label: { fontSize: 16 },
});
Discussion