TypeScript Fundamentals
Key Points
- TypeScript is structural, C# is nominal. Two unrelated types are assignable if their shapes match — no
implementskeyword needed. - Types are erased at compile time. No reflection, no generic type info at runtime.
tscproduces plain JS. unknownis the safeany. Forces you to narrow before use. ForbidanyvianoImplicitAny+--strict.- Utility types (
Pick,Omit,Partial,Required,Record,ReturnType,Awaited) replace 80% of hand-written generic gymnastics. - Generate, don't hand-write, contract types. Use NSwag, Kiota, or OpenAPI Generator to derive TS DTOs from your ASP.NET Core OpenAPI doc.
satisfiesvalidates without widening. Lets you keep narrow literal types while still enforcing a constraint.
Concepts (deep dive)
Structural vs nominal typing
C# (nominal): two types are compatible only if one declares it implements the other. TypeScript (structural): two types are compatible if their shapes match.
interface Named { name: string; }
class Cat { name = "Whiskers"; meow() {} }
const n: Named = new Cat(); // ✅ Cat structurally has `name`. No `implements` required.
⚠️ This bites: any object with the right shape passes the check. Use branded types when you need nominal-like distinction:
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function loadUser(id: UserId) { /* ... */ }
const oid = "ord-123" as OrderId;
loadUser(oid); // ❌ Type 'OrderId' is not assignable to 'UserId'
Type erasure
class Box<T> { items: T[] = []; }
const b = new Box<string>();
// At runtime: b.constructor.name === "Box". The <string> is gone.
No typeof T, no reflection-based generic dispatch. If you need runtime type info, you encode it explicitly (a kind discriminator, a Zod schema, etc.).
unknown vs any vs never
| Read | Write | Narrowing required | |
|---|---|---|---|
any | ✓ | ✓ | No — escape hatch, disables checking |
unknown | ✗ | ✓ | Yes — must narrow before use |
never | — | — | Bottom type — represents impossible values |
function parse(json: string): unknown { return JSON.parse(json); }
const x = parse('{"a":1}');
// x.a; // ❌ Object is of type 'unknown'.
if (typeof x === "object" && x !== null && "a" in x) {
// x is now narrowed
}
never shows up in exhaustiveness checks:
type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.s ** 2;
default: const _: never = s; return _; // compile error if a case is missing
}
}
Generics with constraints
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const out = {} as Pick<T, K>;
for (const k of keys) out[k] = obj[k];
return out;
}
const u = { id: 1, name: "Alice", email: "[email protected]" };
const slim = pick(u, ["id", "name"]); // typed as { id: number; name: string }
Utility types
type User = { id: number; name: string; email: string; createdAt: Date };
type UserUpdate = Partial<User>; // every prop optional
type UserRequired = Required<UserUpdate>; // every prop required
type UserSummary = Pick<User, "id" | "name">; // keep these
type UserNoDates = Omit<User, "createdAt">; // drop these
type UserMap = Record<string, User>; // dictionary
type Handler = (u: User) => Promise<void>;
type HandlerArg = Parameters<Handler>[0]; // User
type HandlerResult = ReturnType<Handler>; // Promise<void>
type HandlerAwaited = Awaited<HandlerResult>; // void
Narrowing
function fmt(x: string | number | Date): string {
if (typeof x === "string") return x.toUpperCase(); // typeof
if (x instanceof Date) return x.toISOString(); // instanceof
return x.toFixed(2); // narrowed to number
}
// `in` narrowing
type A = { kind: "a"; foo: string };
type B = { kind: "b"; bar: number };
function go(v: A | B) {
if ("foo" in v) v.foo; // v: A
else v.bar; // v: B
}
// User-defined type guard
function isError(x: unknown): x is Error {
return x instanceof Error;
}
Discriminated unions are the idiomatic way to model variants — equivalent to F# DUs or C# OneOf<T>:
Conditional and mapped types
// Conditional — type-level if
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<number[]>; // true
type B = IsArray<string>; // false
// Mapped — transform every key
type ReadOnlyDeep<T> = { readonly [K in keyof T]: T[K] extends object ? ReadOnlyDeep<T[K]> : T[K] };
// Key remapping (TS 4.1+)
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type UserGetters = Getters<{ id: number; name: string }>;
// { getId: () => number; getName: () => string }
Template literal types
type Method = "GET" | "POST" | "PUT" | "DELETE";
type Route = `/api/${string}`;
type Endpoint = `${Method} ${Route}`;
const e: Endpoint = "GET /api/users"; // ✅
satisfies operator
Validates without widening — preserves literal types.
const palette = {
red: "#ff0000",
green: [0, 255, 0],
} satisfies Record<string, string | number[]>;
palette.red.toUpperCase(); // ✅ string method (still narrow)
palette.green.length; // ✅ array method (still narrow)
// Without `satisfies`, you'd lose the per-key narrow type.
Declaration merging
interface User { id: number; }
interface User { name: string; }
// Merged into { id: number; name: string }
// Module augmentation — extend a third-party type
declare module "express" {
interface Request { userId?: string; }
}
.d.ts files (ambient declarations)
For libraries shipped without types, or to type a global injected by your host page:
// globals.d.ts
declare global {
interface Window { __APP_CONFIG__: { apiBase: string }; }
}
export {}; // make it a module
tsconfig essentials
{
"compilerOptions": {
"target": "ES2022", // output JS version
"module": "ESNext", // emit ESM
"moduleResolution": "Bundler", // for Vite/webpack/etc
"strict": true, // turns on all strict checks
"noImplicitAny": true, // included in strict
"strictNullChecks": true, // included in strict
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true, // safe for transpile-only tools
"verbatimModuleSyntax": true, // forces explicit `import type`
"skipLibCheck": true, // speed; trust deps' .d.ts
"esModuleInterop": true
}
}
strict: true is non-negotiable in greenfield. The marginal flags (noUncheckedIndexedAccess, exactOptionalPropertyTypes) catch real bugs and should be on by default.
C# DTO interop
Don't hand-maintain TypeScript copies of your C# DTOs — generate them.
| Tool | Source | Output |
|---|---|---|
| NSwag | OpenAPI / Swagger | TS client + DTOs |
| Kiota | OpenAPI | Multi-language client (MS-blessed) |
| OpenAPI Generator | OpenAPI | Many TS variants |
| Swashbuckle CLI | ASP.NET Core → swagger.json | Feeds the above |
| gRPC + ts-proto | .proto | TS contracts from protobuf |
# NSwag CLI example
nswag openapi2tsclient /input:swagger.json /output:src/api/client.ts /template:Fetch
Wire the generation into your CI so drift is caught at build time, not at runtime in front of a customer.
Migrating JS → TS
- Rename
.js→.tsfile by file.allowJs: truelets them coexist. - Start with
strict: false; turn on flags incrementally (noImplicitAnyfirst). - Type the boundaries first: API responses, public exports, route params.
- Use
unknownfor "I don't know yet" — neverany. - Generate types for external contracts (OpenAPI, GraphQL Code Generator).
- Once green, flip
strict: trueand chase the errors module by module.
Code: correct vs wrong
❌ Wrong: any to silence errors
Loses every check. One typo ships to prod.
✅ Correct: unknown + narrow
function handle(req: unknown) {
if (typeof req === "object" && req && "user" in req) {
const user = (req as { user: { name: string } }).user;
return user.name.toUpperCase();
}
throw new Error("bad request");
}
Or better: validate with Zod / io-ts and produce a typed result.
❌ Wrong: hand-written DTOs
// types/user.ts — drift from C# guaranteed
export interface UserDto { id: number; name: string; email: string; }
✅ Correct: generated from OpenAPI
❌ Wrong: type assertion as a hammer
✅ Correct: validate at the boundary
import { z } from "zod";
const UserZ = z.object({ id: z.number(), name: z.string() });
const u: User = UserZ.parse(JSON.parse(s));
Design patterns for this topic
Pattern 1 — "Discriminated union for results"
- Intent: make success/failure paths exhaustive at compile time.
Pattern 2 — "Branded types for IDs"
- Intent: prevent mixing
UserIdandOrderIdat compile time.
Pattern 3 — "Generated client from OpenAPI"
- Intent: zero contract drift between .NET API and TS frontend.
Pattern 4 — "Boundary validation with Zod"
- Intent: types and runtime checks share one source of truth.
Pattern 5 — "as const + satisfies"
- Intent: narrow literal types while constraining shape.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Structural typing | Flexible, ergonomic interop | Accidental compatibility — use brands |
| Type erasure | Zero runtime cost | No reflection — encode runtime shape explicitly |
| Strict mode | Catches real bugs | Migration friction on legacy JS |
| Generated DTOs | No drift | Build-step complexity |
any escape hatch | Pragmatic for migration | Cancer if not contained |
When to use / when to avoid
- Use TS for any non-trivial frontend or shared lib.
- Use
strict: trueon day one in greenfield. - Use generated clients for any cross-process contract.
- Avoid
anyin committed code —unknowninstead. - Avoid modeling deep
instanceofhierarchies — favor discriminated unions.
Interview Q&A
Q1. Structural vs nominal — implications? Anything with the right shape is assignable. Use brands when you need identity (e.g., UserId vs OrderId).
Q2. unknown vs any? any disables checking. unknown requires narrowing before use. Always prefer unknown.
Q3. What does satisfies do? Validates the value matches a constraint without widening it to that constraint's type. Keeps literal types narrow.
Q4. Type erasure — what's gone at runtime? All type annotations, generics, interfaces. Classes survive (they're real JS). No typeof T in generic methods.
Q5. Pick vs Omit? Pick<T, K> keeps keys K. Omit<T, K> drops keys K. Two sides of the same coin.
Q6. Discriminated union? Union where each member has a literal kind field; lets the compiler narrow inside switch/if.
Q7. How do you ensure exhaustive switch? default: const _: never = x; — compile error if a case is missing.
Q8. How do you type a 3rd-party module without types? declare module "lib"; in a .d.ts, or install @types/lib from DefinitelyTyped if available.
Q9. Best way to share C# DTOs with TS? Generate from the OpenAPI document via NSwag, Kiota, or OpenAPI Generator in CI.
Q10. interface vs type? Largely interchangeable. Interfaces support declaration merging; types support unions/intersections/conditionals. Use interface for object shapes you might extend, type for everything else.
Q11. noUncheckedIndexedAccess? Makes arr[i] produce T | undefined. Surfaces a class of off-by-one bugs.
Q12. Conditional vs mapped types? Conditional: T extends U ? X : Y. Mapped: { [K in keyof T]: ... }. Often combined.
Gotchas / common mistakes
- ⚠️
asis a lie — TS believes you. No runtime check. - ⚠️ Structural compatibility surprises — two unrelated types match by shape.
- ⚠️
{}means "any non-nullish" — almost never what you want. UseRecord<string, never>orobject. - ⚠️
FunctionandObjectbuilt-ins — too loose. Avoid; type the actual signature/shape. - ⚠️ Forgetting
import type— accidentally pulls a runtime module just for its types. - ⚠️
enum— emits runtime code, not tree-shakable, has odd numeric semantics. Preferas constobjects + union types. - ⚠️
anyin one place infects every callsite that flows through it.