Skip to content

TypeScript Fundamentals

Key Points

  • TypeScript is structural, C# is nominal. Two unrelated types are assignable if their shapes match — no implements keyword needed.
  • Types are erased at compile time. No reflection, no generic type info at runtime. tsc produces plain JS.
  • unknown is the safe any. Forces you to narrow before use. Forbid any via noImplicitAny + --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.
  • satisfies validates 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>:

type Result<T> = { ok: true; value: T } | { ok: false; error: string };

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

  1. Rename .js.ts file by file. allowJs: true lets them coexist.
  2. Start with strict: false; turn on flags incrementally (noImplicitAny first).
  3. Type the boundaries first: API responses, public exports, route params.
  4. Use unknown for "I don't know yet" — never any.
  5. Generate types for external contracts (OpenAPI, GraphQL Code Generator).
  6. Once green, flip strict: true and chase the errors module by module.

Code: correct vs wrong

❌ Wrong: any to silence errors

function handle(req: any) { return req.user.name.toUpperCase(); }

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

// auto-generated — do not edit
import type { UserDto } from "./api/client.generated";

❌ Wrong: type assertion as a hammer

const u = JSON.parse(s) as User;   // lies — runtime shape unverified

✅ 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.
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };

Pattern 2 — "Branded types for IDs"

  • Intent: prevent mixing UserId and OrderId at 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.
const UserZ = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof UserZ>;

Pattern 5 — "as const + satisfies"

  • Intent: narrow literal types while constraining shape.
const ROUTES = {
  home:  "/",
  users: "/users",
} as const satisfies Record<string, `/${string}`>;

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: true on day one in greenfield.
  • Use generated clients for any cross-process contract.
  • Avoid any in committed code — unknown instead.
  • Avoid modeling deep instanceof hierarchies — 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

  • ⚠️ as is 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. Use Record<string, never> or object.
  • ⚠️ Function and Object built-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. Prefer as const objects + union types.
  • ⚠️ any in one place infects every callsite that flows through it.

Further reading