Types That Earn Their Keep
Model a screen's states as one union, keep literal types with as const satisfies, and treat any, as and ! as the costs they are.
const palette = { warning: '#ffd8a8' } satisfies PaletteTypes pay for themselves where they delete a state the app should never reach. Three booleans - loading, error, hasItems - describe eight combinations, and you wanted three.
Unions for screen states
Write the three as one union sharing a literal field: { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; items: Item[] }. That field is the discriminant. Checking it narrows the whole object, so message exists only inside the error branch and "loading with an error" cannot be written at all. A switch whose default assigns the value to a never variable then fails to compile the day someone adds a fourth state without rendering it.
satisfies
satisfies (TypeScript 4.9) checks a value against a type without replacing the value's own inferred type, which is what an annotation does. const palette: Palette = { ... } checks and then makes every value string. const palette = { ... } satisfies Palette checks and keeps whatever was inferred - but a mutable property widens its string literal anyway, so palette.warning is still string. To keep the literal, pair the two: const palette = { ... } as const satisfies Palette leaves '#ffd8a8' as '#ffd8a8' while still checking the object against Palette. Use that pair for config objects, theme maps and route tables you want to read exact values back out of.
any and the assertions
any turns checking off and spreads: anything derived from it is unchecked too. unknown is the honest alternative and forces a narrowing. as and ! are promises to the compiler, not tests - the value is unchanged, so the crash still arrives, further from its cause.
strict
The Expo template sets "strict": true. Its most valuable part is strictNullChecks: without it array.find(...) reads as a definite value and the undefined case vanishes from the types.
Example
// src/components/ItemsView.tsx
import { StyleSheet, Text, View } from 'react-native';
// One union instead of three booleans. 'loading with an error' is not
// expressible, so it cannot reach the screen. 'status' is the
// discriminant: checking it narrows the rest of the object.
export type ScreenState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; items: string[] };
type ItemsViewProps = { state: ScreenState };
export default function ItemsView({ state }: ItemsViewProps) {
switch (state.status) {
case 'loading':
return <Text style={styles.text}>Loading</Text>;
case 'error':
// state.message exists only in this branch
return <Text style={styles.text}>{state.message}</Text>;
case 'ready':
return (
<View style={styles.list}>
{state.items.map((item) => (
<Text key={item} style={styles.text}>
{item}
</Text>
))}
</View>
);
default: {
// Add a fourth state and 'state' is no longer 'never' here,
// so this line stops compiling. An exhaustiveness check.
const unreachable: never = state;
return unreachable;
}
}
}
const styles = StyleSheet.create({
list: { padding: 16, gap: 8 },
text: { fontSize: 16, padding: 16 },
});When to use it
- Make "loading and error at the same time" impossible to put on screen.
- Force the build to fail when a new screen state is added but never rendered.
- Keep exact colour or key literals in a theme object so other types can use them.
- Replace a non-null assertion that crashes in production with a check the compiler enforces.
More examples
satisfies next to the annotation it replaces
The two forms look alike and differ in what survives. Both reject a missing tone or a number where a string belongs; The three forms look alike and differ in what survives. All of them reject a missing tone or a number where a string belongs; what changes is the type you can read back out. The annotation replaces the inferred type with Palette; satisfies keeps the inferred type, but a mutable property widens its literal, so warning is still string; as const satisfies keeps '#ffd8a8' as its own type, which is what lets you build further types from the object - keyof typeof palette, or a prop that accepts only colours actually in the theme. Reach for as const satisfies on any constant table: a theme, a feature-flag default set, an icon map., which is what lets you build further types from the object - keyof typeof palette, or a prop that accepts only colours actually in the theme. Reach for satisfies on any constant table: a theme, a feature-flag default set, an icon map.
// src/theme.ts
type Tone = 'neutral' | 'warning' | 'danger';
type Palette = Record<Tone, string>;
// Annotation: checked against Palette, and every value becomes string.
const annotated: Palette = {
neutral: '#e6e8eb',
warning: '#ffd8a8',
danger: '#ffc9c9',
};
// satisfies on its own: checked against Palette, and the inferred type
// is kept - but the properties are mutable, so each literal still
// widens to string.
const widened = {
neutral: '#e6e8eb',
warning: '#ffd8a8',
danger: '#ffc9c9',
} satisfies Palette;
// 'as const' keeps the literals; 'satisfies Palette' still checks them.
const palette = {
neutral: '#e6e8eb',
warning: '#ffd8a8',
danger: '#ffc9c9',
} as const satisfies Palette;
type AnnotatedWarning = typeof annotated.warning; // string
type WidenedWarning = typeof widened.warning; // string
type ExactWarning = typeof palette.warning; // '#ffd8a8'
console.log(palette.warning.toUpperCase());
// Output: #FFD8A8
// A missing key fails in all three - that is the 'Palette' part still
// doing its job.A guard where a non-null assertion was
Same two lines of work, two different contracts. The first compiles and then throws "Cannot read property 'token' of null" somewhere downstream; the second makes the empty case part of the return type, so every caller is reminded it exists. The rule of thumb: when you are tempted by ! or as, there is a check you have decided not to write - write it, and the compiler will narrow for you.
// src/auth/header.ts
type Session = { token: string } | null;
// '!' says 'trust me, it is there'. It checks nothing, so an empty
// session still crashes - at the fetch, not here.
function headerUnsafe(session: Session): string {
return `Bearer ${session!.token}`;
}
// A narrowing check instead. The caller is handed something it can
// actually deal with.
export function header(session: Session): string | null {
if (session === null) return null;
// session is { token: string } from here on
return `Bearer ${session.token}`;
}
console.log(header({ token: 'abc123' }));
// Output: Bearer abc123
console.log(header(null));
// Output: nullWhat strictNullChecks actually buys
find, at, pop, a Map get, a route param, a value from storage: all of them can come back with nothing, and strictNullChecks is what puts that in the type. The ?. and ?? pair is the short way to handle it - match?.label is the same as (match === null || match === undefined) ? undefined : match.label, and ?? supplies the fallback for undefined or null only, unlike || which also fires on an empty string. Keep strict on in a new project; turning it off hides exactly the bugs that crash on a device and not in the simulator.
// src/items.ts
// tsconfig.json has "strict": true, which switches on strictNullChecks.
type Item = { id: string; label: string };
const items: Item[] = [
{ id: 'a', label: 'Alpha' },
{ id: 'b', label: 'Beta' },
];
export function labelFor(id: string): string {
// find returns Item | undefined. Without strictNullChecks it would
// read as Item and the next line would compile with no '?.' at all.
const match = items.find((item) => item.id === id);
return match?.label ?? 'unknown';
}
console.log(labelFor('a'));
// Output: Alpha
console.log(labelFor('zz'));
// Output: unknown
Discussion