Skip to main content
Published / updated

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:

  • $.ajax calls error for 4xx and 5xx. fetch resolves and needs an explicit response.ok check.
  • data is 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

jQueryModern
$('#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
$.ajaxfetch
$(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:

  1. Add TypeScript with allowJs and strict: false.
  2. Rename one file to .ts and fix its errors.
  3. Add types to the modules it depends on.
  4. Turn on strict once most files are converted.
  5. Add checkJs to catch errors in the remaining .js files.
// @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

MessageCauseFix
$ is not definedjQuery not loaded, or loaded after your scriptLoad it first
TS2322: Type 'string' is not assignable to type 'number'Type mismatch — this is TypeScript workingConvert explicitly
TS2531: Object is possibly 'null'Strict null checksCheck for null first
TS7006: Parameter implicitly has an 'any' typeNo type annotation with noImplicitAnyAnnotate it
Cannot find module './x'Wrong path or missing extension settingCheck 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
  • $.ajax POST without contentType: 'application/json', causing 415
  • Converting working jQuery as an incidental change

TypeScript

  • any anywhere it can be avoided
  • strict: false deferred and never turned on
  • as used to silence an error instead of narrowing
  • Trusting as T on 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.

  1. Take a jQuery page and list every jQuery call. Write the modern equivalent beside each.
  2. Convert one jQuery event handler to addEventListener with delegation.
  3. Use an arrow function as a jQuery handler with $(this). Record what breaks, then fix it.
  4. Set a value with .data('id', 5) then read .attr('data-id'). Explain the result.
  5. POST JSON with $.ajax and no contentType. Confirm 415 in the Network tab, then fix it.
  6. Set up TypeScript with strict: true and sourceMap: true.
  7. Define Student, StudentStatus and PagedResult<T>. Derive StudentInput with Omit.
  8. Write apiRequest<T> returning Promise<T>. Call it with the wrong type argument and see what the compiler does — and does not — catch.
  9. Add a runtime type guard isStudent and validate the response. Change a property name on the server and confirm the guard catches it where as T did not.
  10. Model loading, success and error with a discriminated union. Try to access data in the error branch.
  11. Write an exhaustive switch over StudentStatus with the never check. Add a fifth status and confirm the compile error.
  12. Type a getElementById result and handle the null case. Remove strictNullChecks and observe what stops being caught.
  13. Add // @ts-check to an existing .js file 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 any and explain why
  • Define an interface matching an API response

Review questions

  1. Why does an arrow function break $(this) in a jQuery handler?
  2. Why does $.ajax POST need contentType: 'application/json' for a .NET API?
  3. What is the difference between any and unknown?
  4. Why does as T on an API response not make it safe?

Next: Frontend project