Essential jQuery and TypeScript
Before you start
You need: JavaScript and fetch (Articles 07–10).
Time: about 50 minutes, plus the practice. jQuery is here so you can maintain what exists; TypeScript is what you will write.
Learning objective
Read and maintain jQuery in an existing codebase, and write TypeScript that catches errors before they run.
Topics
- Why jQuery is still here
- Selection, DOM and events in jQuery
- AJAX in jQuery
- The modern equivalent of each
- TypeScript setup and basic types
- Interfaces and type aliases
- Union types and narrowing
- Generics
- Typing DOM and API code
- Migrating incrementally
Part 1 — jQuery
Why it is still here
jQuery solved real problems in 2006: browsers disagreed on everything, and document.querySelector did not exist. It is still on a large share of the web, and you will meet it in any ASP.NET Web Forms or MVC application built before about 2018.
You are unlikely to start a project with it. You are quite likely to maintain one. Learn enough to read it and to convert it deliberately.
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
Selection and the jQuery object
$('#studentTable') // by id
$('.student-card') // by class
$('tr[data-id]') // any CSS selector
$(document)
$(this)
$() always returns a collection, even for one element — and methods run on every item with no loop:
$('.student-card').addClass('highlighted'); // all of them
That implicit iteration is jQuery's main convenience, and the main surprise when converting: document.querySelector returns one element, $() returns a set.
An empty selection does not throw. $('#missing').addClass('x') silently does nothing, where document.getElementById('missing').classList throws. Convenient, and it hides typos — a jQuery selector that matches nothing gives no clue that anything is wrong.
if ($('#studentTable').length === 0) {
console.error('Table not found');
}
Converting between the two:
const element = $('#studentTable')[0]; // jQuery → DOM
const element2 = $('#studentTable').get(0);
const wrapped = $(document.getElementById('studentTable')); // DOM → jQuery
DOM and events
$('#status').text('Saved'); // textContent
$('#status').html('<b>Saved</b>'); // innerHTML
$('#rollNumber').val(); // input value
$('#rollNumber').val('NCA-2024-0012');
$('.card').addClass('active').removeClass('hidden').toggleClass('open');
$('#row').attr('data-id', '42');
$('#row').data('id'); // reads data-id, and caches it
$('#panel').show().hide().fadeIn(200).slideUp(200);
$('#studentRows').append(html).prepend(html).empty().remove();
.data() caches values in jQuery's internal store, so .data('id', 5) does not update the data-id attribute. Reading it back with .attr('data-id') gives the old value. This trips people up constantly — use .attr() when the attribute itself matters.
$('#btnSearch').on('click', function (event) {
event.preventDefault();
loadStudents($('#txtSearch').val());
});
// Delegation — the important one
$('#studentRows').on('click', '.delete-button', function () {
const id = $(this).closest('tr').data('publicId');
deleteStudent(id);
});
Inside a jQuery handler, this is the DOM element, not a jQuery object — hence $(this).
An arrow function breaks that, because arrows have no this of their own:
$('#btnSearch').on('click', () => {
$(this).addClass('active'); // `this` is not the button
});
Use event.currentTarget in an arrow function, or a regular function.
Document ready:
$(document).ready(function () { });
$(function () { }); // shorthand
Not needed with defer on the script tag or DOMContentLoaded.
AJAX
$.ajax({
url: '/api/students',
method: 'GET',
data: { schoolId: 1, className: '10th' },
dataType: 'json',
success: function (data) { renderStudents(data); },
error: function (xhr, status, error) { showError(xhr.status); },
complete: function () { hideSpinner(); }
});
$.get('/api/students', { schoolId: 1 }, renderStudents);
$.post('/api/students', studentData, onSaved);
$.getJSON('/api/students', renderStudents);
Two differences from fetch that matter:
$.ajaxcallserrorfor 4xx and 5xx.fetchresolves and needs an explicitresponse.okcheck.datais serialised as form-encoded by default, not JSON. For a JSON API you must say so:
$.ajax({
url: '/api/students',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(student),
success: onSaved
});
Omitting contentType sends application/x-www-form-urlencoded, and an ASP.NET Core [FromBody] endpoint returns 415.
For an ASP.NET Core antiforgery-protected endpoint:
$.ajaxSetup({
headers: {
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
}
});
jQuery to modern equivalents
| jQuery | Modern |
|---|---|
$('#id') | document.getElementById('id') |
$('.class') | document.querySelectorAll('.class') |
$(el).addClass('x') | el.classList.add('x') |
$(el).text(v) | el.textContent = v |
$(el).val() | el.value |
$(el).attr('a', v) | el.setAttribute('a', v) |
$(el).data('id') | el.dataset.id |
$(el).on('click', fn) | el.addEventListener('click', fn) |
$(p).on('click', '.c', fn) | Delegation with closest() |
$(el).closest('tr') | el.closest('tr') |
$(el).hide() | el.hidden = true |
$.ajax | fetch |
$(document).ready(fn) | defer, or DOMContentLoaded |
$.each(arr, fn) | arr.forEach(fn) |
Everything jQuery provided is now native. Its remaining value in a legacy project is the plugins built on it.
Do not convert a working jQuery page as an incidental change. It is a rewrite with its own testing, not a cleanup to attach to a bug fix.
Part 2 — TypeScript
What it adds
TypeScript is JavaScript with types, checked at compile time and erased at run time. The browser never sees a type.
function calculatePercentage(marksObtained: number, maxMarks: number): number {
if (maxMarks <= 0) {
throw new Error('maxMarks must be positive');
}
return (marksObtained / maxMarks) * 100;
}
calculatePercentage('87', 100); // compile error — caught before running
Types are erased. They catch mistakes while you write; they enforce nothing at run time. Data arriving from an API is unvalidated regardless of how it is typed.
Setup
npm install --save-dev typescript
npx tsc --init
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true
},
"include": ["src/**/*"]
}
strict: true from the first day. Turning it on later in a large codebase produces hundreds of errors at once and the work gets abandoned. It enables strictNullChecks, which is where most of the value is.
noUncheckedIndexedAccess makes array[0] typed as T | undefined, forcing you to handle an out-of-range index. Stricter than most projects use, and it catches real bugs.
sourceMap: true lets DevTools show your .ts files when debugging.
Basic types
let name: string = 'Ravi Kumar';
let marks: number = 87;
let isActive: boolean = true;
let publicId: string | null = null;
let names: string[] = ['Ravi', 'Priya'];
let scores: Array<number> = [87, 91];
let pair: [string, number] = ['Ravi', 87]; // tuple
let anything: any; // opts out of checking entirely — avoid
let something: unknown; // must be narrowed before use — prefer this
function logAndThrow(message: string): never {
throw new Error(message);
}
any disables type checking for everything it touches, and it spreads — a value typed any passed into a function makes that call unchecked too. unknown is the safe version:
function process(value: unknown) {
// value.toUpperCase(); // error — must narrow first
if (typeof value === 'string') {
value.toUpperCase(); // narrowed to string
}
}
Type inference means most annotations are unnecessary:
const name = 'Ravi Kumar'; // inferred as string
const marks = [87, 91]; // inferred as number[]
Annotate function parameters and return types; let everything else infer. The return annotation is what catches a branch that forgets to return.
Interfaces and type aliases
interface Student {
id: number;
publicId: string;
schoolId: number;
name: string;
rollNumber: string;
className: string;
section: string;
dateOfBirth: string;
parentName: string;
parentPhone: string;
address?: string; // optional
readonly createdAt: string; // cannot be reassigned
}
type StudentStatus = 'Active' | 'Inactive' | 'Graduated' | 'Transferred';
type PagedResult<T> = {
items: T[];
totalCount: number;
page: number;
pageSize: number;
};
interface and type overlap heavily. interface can be reopened and extended; type can express unions and mapped types. Use interface for object shapes and type for everything else is a reasonable default.
interface Teacher extends Person {
employeeCode: string;
}
type StudentSummary = Pick<Student, 'publicId' | 'name' | 'rollNumber'>;
type StudentInput = Omit<Student, 'id' | 'publicId' | 'createdAt'>;
type PartialStudent = Partial<Student>; // every property optional
type RequiredStudent = Required<Student>;
type StudentMap = Record<string, Student>;
Pick and Omit derive DTOs from an entity, so a renamed property breaks the DTO at compile time rather than silently.
Union types and narrowing
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string; code: number }
| { status: 'loading' };
function render(result: Result<Student[]>): void {
switch (result.status) {
case 'loading':
showSpinner();
break;
case 'success':
renderStudents(result.data); // `data` exists only here
break;
case 'error':
showError(result.message, result.code);
break;
}
}
A discriminated union — the status field narrows the type in each branch, so accessing result.data in the error branch is a compile error. This models loading, success and error states far better than three separate booleans.
function describe(value: string | number): string {
if (typeof value === 'number') {
return value.toFixed(2);
}
return value.toUpperCase();
}
function isStudent(value: unknown): value is Student {
return typeof value === 'object'
&& value !== null
&& 'rollNumber' in value
&& 'className' in value;
}
value is Student is a type predicate — it tells the compiler what a runtime check proves. This is how you safely narrow unvalidated API data.
Exhaustiveness
function getLabel(status: StudentStatus): string {
switch (status) {
case 'Active': return 'Active';
case 'Inactive': return 'Inactive';
case 'Graduated': return 'Graduated';
case 'Transferred': return 'Transferred';
default:
const exhaustive: never = status;
return exhaustive;
}
}
Adding a member to StudentStatus now produces a compile error here, because the new value is not assignable to never. Without it the function silently returns undefined for the new case.
Generics
function first<T>(items: T[]): T | undefined {
return items[0];
}
const student = first(students); // T inferred as Student
interface Repository<T> {
getById(id: number): Promise<T | null>;
getAll(): Promise<T[]>;
create(item: Omit<T, 'id'>): Promise<T>;
}
function getProperty<T, K extends keyof T>(item: T, key: K): T[K] {
return item[key];
}
const name = getProperty(student, 'name'); // typed string
// getProperty(student, 'invalid'); // compile error
K extends keyof T constrains the key to properties that actually exist, and the return type follows. That is the kind of safety generics buy.
Typing DOM and API code
const searchBox = document.getElementById('searchBox') as HTMLInputElement | null;
if (searchBox === null) {
throw new Error('Search input not found');
}
const term: string = searchBox.value;
getElementById returns HTMLElement | null, which has no .value — so the cast is needed, and strictNullChecks forces the null check. Both are the compiler pointing at real failure modes.
const rows = document.querySelectorAll<HTMLTableRowElement>('tr[data-id]');
form.addEventListener('submit', (event: SubmitEvent) => {
event.preventDefault();
});
input.addEventListener('input', (event: Event) => {
const target = event.target as HTMLInputElement;
search(target.value);
});
class ApiError extends Error {
constructor(public readonly status: number, public readonly body: unknown) {
super(`API request failed with status ${status}`);
this.name = 'ApiError';
}
}
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...options,
headers: {
'Accept': 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...options.headers
}
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
if (response.status === 204) {
return null as T;
}
return await response.json() as T;
}
const result = await apiRequest<PagedResult<Student>>('/api/students?schoolId=1');
as T is a promise, not a check. The compiler now believes the response is a PagedResult<Student>; nothing verified it. A backend change silently produces objects that do not match, and the failure appears somewhere far from the cause.
For data that matters, validate at the boundary:
function parseStudent(value: unknown): Student {
if (!isStudent(value)) {
throw new Error('Response did not match the Student shape');
}
return value;
}
A runtime validation library (Zod, Valibot) does this properly and derives the TypeScript type from the schema, so there is one source of truth.
Migrating incrementally
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"strict": false
}
}
TypeScript compiles JavaScript, so a project can migrate file by file:
- Add TypeScript with
allowJsandstrict: false. - Rename one file to
.tsand fix its errors. - Add types to the modules it depends on.
- Turn on
strictonce most files are converted. - Add
checkJsto catch errors in the remaining.jsfiles.
// @ts-check
That comment at the top of a .js file enables checking with no rename at all — a good first step, and it finds real bugs immediately.
npm install --save-dev @types/jquery
Type definitions for a library that has none. Most popular libraries ship their own now.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
$ is not defined | jQuery not loaded, or loaded after your script | Load it first |
TS2322: Type 'string' is not assignable to type 'number' | Type mismatch — this is TypeScript working | Convert explicitly |
TS2531: Object is possibly 'null' | Strict null checks | Check for null first |
TS7006: Parameter implicitly has an 'any' type | No type annotation with noImplicitAny | Annotate it |
Cannot find module './x' | Wrong path or missing extension setting | Check the import path |
Every TypeScript error is a run-time bug it caught early. Reaching for any to silence one throws away the reason you are using TypeScript.
Common mistakes
jQuery
- Assuming
$()returns one element rather than a set - Not checking
.length— an empty selection fails silently - An arrow function as a jQuery handler, losing
this .data()set expecting.attr('data-x')to reflect it$.ajaxPOST withoutcontentType: 'application/json', causing 415- Converting working jQuery as an incidental change
TypeScript
anyanywhere it can be avoidedstrict: falsedeferred and never turned onasused to silence an error instead of narrowing- Trusting
as Ton API data as though it validated - Annotating what the compiler already infers
- No exhaustiveness check on a union
switch - No
sourceMap, so debugging shows compiled output
Practice
The course exercise is the typed API client assignment.
- Take a jQuery page and list every jQuery call. Write the modern equivalent beside each.
- Convert one jQuery event handler to
addEventListenerwith delegation. - Use an arrow function as a jQuery handler with
$(this). Record what breaks, then fix it. - Set a value with
.data('id', 5)then read.attr('data-id'). Explain the result. - POST JSON with
$.ajaxand nocontentType. Confirm 415 in the Network tab, then fix it. - Set up TypeScript with
strict: trueandsourceMap: true. - Define
Student,StudentStatusandPagedResult<T>. DeriveStudentInputwithOmit. - Write
apiRequest<T>returningPromise<T>. Call it with the wrong type argument and see what the compiler does — and does not — catch. - Add a runtime type guard
isStudentand validate the response. Change a property name on the server and confirm the guard catches it whereas Tdid not. - Model loading, success and error with a discriminated union. Try to access
datain the error branch. - Write an exhaustive
switchoverStudentStatuswith thenevercheck. Add a fifth status and confirm the compile error. - Type a
getElementByIdresult and handle thenullcase. RemovestrictNullChecksand observe what stops being caught. - Add
// @ts-checkto an existing.jsfile and fix what it reports.
Exercises 9 and 11 are the two that show what TypeScript is genuinely for.
You can now
- Read and maintain jQuery in an existing project
- Write typed TypeScript with interfaces and generics
- Say what each common TS error is protecting you from
- Avoid
anyand explain why - Define an interface matching an API response
Review questions
- Why does an arrow function break
$(this)in a jQuery handler? - Why does
$.ajaxPOST needcontentType: 'application/json'for a .NET API? - What is the difference between
anyandunknown? - Why does
as Ton an API response not make it safe?
Next: Frontend project