JavaScript keeps gaining small, sharp features, and TypeScript keeps turning runtime mistakes into compile-time errors. This guide covers the parts that matter for production front-end code today, with examples.
JavaScript: the modern essentials
Optional chaining and nullish coalescing
Read nested data without exploding, and fall back only on null/undefined.
const city = user?.address?.city
const label = name ?? 'Anonymous'
const retries = config?.retries ?? 3Logical assignment
Mutate a variable only when it is falsy, nullish, or truthy.
user.nickname ||= user.firstName
settings.theme ??= 'dark'Non-mutating array methods
toSorted, toReversed, toSpliced, and with return new arrays instead of mutating, which pairs perfectly with immutable state.
const sorted = items.toSorted((a, b) => a.score - b.score)
const reversed = items.toReversed()
const first = items.at(0)
const last = items.at(-1)Grouping and cloning
Object.groupBy buckets a collection in one line, and structuredClone deep copies without JSON hacks.
const byStatus = Object.groupBy(orders, (o) => o.status)
const copy = structuredClone(original)Promise helpers
Promise.withResolvers gives you the resolve/reject functions without wrapping a promise in a constructor.
const { promise, resolve, reject } = Promise.withResolvers<string>()
async function nextMessage(): Promise<string> {
return promise
}Array.fromAsync turns an async iterable into an array.
const lines = await Array.fromAsync(stream)Promises and Observables
Promises
A promise represents a value that will be available later. async/await flattens the callbacks.
function fetchUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((res) => res.json())
}
async function loadUser(id: number): Promise<User | null> {
try {
return await fetchUser(id)
} catch (error) {
console.error('failed to load user', error)
return null
}
}Run independent work in parallel and collect the results:
const [a, b, c] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
])Observables
Observables (RxJS) model a stream of values over time. Build pipelines from operators and always unsubscribe.
import { from, fromEvent } from 'rxjs'
import { debounceTime, filter, map, switchMap } from 'rxjs/operators'
const search$ = fromEvent<InputEvent>(input, 'input').pipe(
debounceTime(300),
map((event) => (event.target as HTMLInputElement).value),
filter((term) => term.length >= 3),
switchMap((term) =>
from(fetch(`/api/search?q=${term}`).then((res) => res.json())),
),
)
const subscription = search$.subscribe({
next: (results) => console.log(results),
error: (err) => console.error(err),
})
subscription.unsubscribe()A promise produces one value; an observable produces many over time. Use promises for single results, observables for events and streams.
TypeScript: the type system that matters
unknown beats any
any turns the type checker off; unknown forces you to narrow first.
function parse(value: unknown): string {
if (typeof value === 'string') return value
throw new Error('not a string')
}satisfies and as const
satisfies checks an object against a type without widening it, and as const locks values to their literal types.
const palette = {
primary: '#c770f0',
danger: '#ef4444',
} satisfies Record<string, string>
const routes = ['/', '/about', '/blog'] as const
type Route = (typeof routes)[number]Discriminated unions
Model each state with a shared kind field, and the compiler narrows it for you — the backbone of typed reducers and state machines.
type State =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: User[] }
| { kind: 'error'; message: string }
function render(state: State): string {
switch (state.kind) {
case 'idle': return 'Waiting…'
case 'loading': return 'Loading…'
case 'success': return state.data.map((u) => u.name).join(', ')
case 'error': return state.message
}
}Type predicates
Narrow unknown into a concrete shape safely.
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}Template literal types
Build string types from unions — great for typed routes and API paths.
type Route = `/api/${'users' | 'orders'}/${string}`const type parameters
Keep tuple literals precise without spreading as const everywhere.
function preserve<const T>(items: T[]): T[] {
return items
}
const pair = preserve([1, 'two']) // [number, string]Utility types
Compose new types from existing ones instead of hand-writing them.
type UserInput = Omit<User, 'id' | 'createdAt'>
type PartialUser = Partial<User>
type ReadonlyUser = Readonly<User>Recent TypeScript features
Standard decorators
Decorators are now part of the language, no experimental flag required.
function logged(target: unknown, context: ClassMethodDecoratorContext) {
// wrap the method
}
class Service {
@logged
fetchData() {
/* ... */
}
}Explicit resource management
using runs cleanup deterministically via Symbol.dispose.
function readFile(path: string) {
using handle = openFile(path)
return handle.read()
}NoInfer
Stop the compiler from inferring a type argument in a specific position.
declare function createPair<T>(first: T, second: NoInfer<T>): T
const result = createPair('a', 'b') // T is inferred from the first arg onlyArchitecture patterns
Result types instead of exceptions
Represent success and failure in the type, so callers cannot forget to handle errors.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
function divide(a: number, b: number): Result<number> {
if (b === 0) return { ok: false, error: new Error('division by zero') }
return { ok: true, value: a / b }
}Exhaustive checks
A never helper guarantees every case is handled when the union grows.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}
// switch (state.kind) { ... default: assertNever(state) }Branded types
Prevent mixing up identifiers that share the same underlying type.
type UserId = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }
function toUserId(id: string): UserId {
return id as UserId
}Immutability by default
Prefer readonly, as const, and non-mutating methods so state changes are explicit.
interface Config {
readonly apiUrl: string
readonly retries: number
}ESM and tree-shaking
Use ES modules and named imports so bundlers can drop unused code, and prefer type-only imports so types never leak into runtime bundles.
import type { User } from './models'
import { fetchUsers } from './api'Wrapping up
The modern front-end expert leans on the sharp new JavaScript APIs for data handling, and on TypeScript's discriminated unions, utility types, and result types to push errors to compile time. Adopt these patterns and your code gets smaller, safer, and far easier to reason about.